diff --git a/codeframe/core/proof/ledger.py b/codeframe/core/proof/ledger.py index 69e5fa4c..28cf5eac 100644 --- a/codeframe/core/proof/ledger.py +++ b/codeframe/core/proof/ledger.py @@ -137,6 +137,7 @@ def _waiver_to_json(waiver: Optional[Waiver]) -> Optional[str]: "expires": waiver.expires.isoformat() if waiver.expires else None, "manual_checklist": waiver.manual_checklist, "approved_by": waiver.approved_by, + "waived_at": waiver.waived_at.isoformat() if waiver.waived_at else None, }) @@ -144,11 +145,13 @@ def _waiver_from_json(raw: Optional[str]) -> Optional[Waiver]: if not raw: return None data = json.loads(raw) + waived_at_raw = data.get("waived_at") return Waiver( reason=data["reason"], expires=date.fromisoformat(data["expires"]) if data.get("expires") else None, manual_checklist=data.get("manual_checklist", []), approved_by=data.get("approved_by", ""), + waived_at=datetime.fromisoformat(waived_at_raw) if waived_at_raw else None, ) @@ -317,6 +320,14 @@ def waive_requirement( workspace: Workspace, req_id: str, waiver: Waiver ) -> Optional[Requirement]: """Waive a requirement with reason and optional expiry.""" + if waiver.waived_at is None: + waiver = Waiver( + reason=waiver.reason, + expires=waiver.expires, + manual_checklist=waiver.manual_checklist, + approved_by=waiver.approved_by, + waived_at=datetime.now(timezone.utc), + ) _ensure_tables(workspace) conn = get_db_connection(workspace) cursor = conn.cursor() diff --git a/codeframe/core/proof/models.py b/codeframe/core/proof/models.py index 5e840ef7..49c4ffcc 100644 --- a/codeframe/core/proof/models.py +++ b/codeframe/core/proof/models.py @@ -98,6 +98,7 @@ class Waiver: expires: Optional[date] = None manual_checklist: list[str] = field(default_factory=list) approved_by: str = "" + waived_at: Optional[datetime] = None @dataclass diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py index 7eecdbd8..b40e410e 100644 --- a/codeframe/ui/routers/proof_v2.py +++ b/codeframe/ui/routers/proof_v2.py @@ -100,6 +100,7 @@ class WaiverOut(BaseModel): expires: Optional[str] manual_checklist: list[str] approved_by: str + waived_at: Optional[str] = None class RequirementResponse(BaseModel): @@ -195,6 +196,7 @@ def _req_to_response(req) -> RequirementResponse: expires=req.waiver.expires.isoformat() if req.waiver.expires else None, manual_checklist=req.waiver.manual_checklist, approved_by=req.waiver.approved_by, + waived_at=req.waiver.waived_at.isoformat() if req.waiver.waived_at else None, ) if req.waiver else None, created_at=req.created_at.isoformat() if req.created_at else None, satisfied_at=req.satisfied_at.isoformat() if req.satisfied_at else None, diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 09ef8629..088e5f4d 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -60,4 +60,6 @@ module.exports = { Alert01Icon: createIconMock('Alert01Icon'), // SplitPane ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'), + // Proof page + InformationCircleIcon: createIconMock('InformationCircleIcon'), }; diff --git a/web-ui/__tests__/app/proof/page.test.tsx b/web-ui/__tests__/app/proof/page.test.tsx new file mode 100644 index 00000000..054e40f1 --- /dev/null +++ b/web-ui/__tests__/app/proof/page.test.tsx @@ -0,0 +1,218 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ProofPage from '@/app/proof/page'; +import { localStorageMock } from '../../utils/test-helpers'; + +jest.mock('@/lib/api', () => ({ + proofApi: { + listRequirements: jest.fn(), + waive: jest.fn(), + }, +})); + +jest.mock('@/lib/workspace-storage', () => ({ + getSelectedWorkspacePath: jest.fn(() => '/test/workspace'), +})); + +jest.mock('swr', () => ({ __esModule: true, default: jest.fn() })); + +import useSWR from 'swr'; +import { proofApi } from '@/lib/api'; + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockWaive = proofApi.waive as jest.MockedFunction; + +const openReq = { + id: 'REQ-001', + title: 'Test requirement', + description: 'A test requirement', + severity: 'high', + status: 'open', + glitch_type: 'regression', + obligations: [], + evidence_rules: [], + waiver: null, + created_at: '2026-01-01T00:00:00Z', + satisfied_at: null, + created_by: 'tester', + source_issue: null, + related_reqs: [], + source: 'manual', +}; + +const waivedReq = { + ...openReq, + id: 'REQ-002', + title: 'Waived requirement', + status: 'waived', + waiver: { + reason: 'Not applicable for this release', + expires: null, + manual_checklist: [], + approved_by: 'frank', + waived_at: '2026-03-01T12:00:00Z', + }, +}; + +describe('ProofPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockUseSWR.mockReturnValue({ + data: { + requirements: [openReq, waivedReq], + total: 2, + by_status: { open: 1, waived: 1, satisfied: 0 }, + }, + error: undefined, + isLoading: false, + mutate: jest.fn(), + } as any); + }); + + describe('waived row visual treatment', () => { + it('renders waived rows with muted/strikethrough styling', async () => { + render(); + await waitFor(() => screen.getByText('Waived requirement')); + + const waivedRow = screen.getByText('Waived requirement').closest('tr'); + expect(waivedRow).toHaveClass('opacity-60'); + }); + + it('does not apply muted styling to open rows', async () => { + render(); + await waitFor(() => screen.getByText('Test requirement')); + + const openRow = screen.getByText('Test requirement').closest('tr'); + expect(openRow).not.toHaveClass('opacity-60'); + }); + + it('does not show Waive button for waived requirements', async () => { + render(); + await waitFor(() => screen.getByText('Waived requirement')); + + const buttons = screen.getAllByRole('button', { name: /waive/i }); + // Only one Waive button for the open req + expect(buttons).toHaveLength(1); + }); + }); + + describe('WaiveDialog — 2-step confirmation flow', () => { + it('opens the form step when Waive is clicked', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => { + expect(screen.getByText(/waive req-001/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/reason/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); + }); + }); + + it('shows error if Continue is clicked without a reason', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => screen.getByRole('button', { name: /continue/i })); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => { + expect(screen.getByText(/reason is required/i)).toBeInTheDocument(); + }); + }); + + it('advances to confirmation step when reason is provided', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Not needed this cycle' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => { + expect(screen.getByText(/marked satisfied without evidence/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm waive/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + }); + }); + + it('shows the entered reason in the confirmation summary', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Accepted risk for v1' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => { + expect(screen.getByText('Accepted risk for v1')).toBeInTheDocument(); + }); + }); + + it('goes back to form when Back is clicked', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Temporary waiver' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => screen.getByRole('button', { name: /back/i })); + fireEvent.click(screen.getByRole('button', { name: /back/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/reason/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('Temporary waiver')).toBeInTheDocument(); + }); + }); + + it('calls proofApi.waive and closes on Confirm Waive', async () => { + mockWaive.mockResolvedValueOnce(undefined as any); + const mutate = jest.fn(); + mockUseSWR.mockReturnValue({ + data: { + requirements: [openReq, waivedReq], + total: 2, + by_status: { open: 1, waived: 1 }, + }, + error: undefined, + isLoading: false, + mutate, + } as any); + + render(); + await waitFor(() => screen.getByRole('button', { name: /^waive$/i })); + fireEvent.click(screen.getByRole('button', { name: /^waive$/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Risk accepted' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => screen.getByRole('button', { name: /confirm waive/i })); + fireEvent.click(screen.getByRole('button', { name: /confirm waive/i })); + + await waitFor(() => { + expect(mockWaive).toHaveBeenCalledWith('/test/workspace', 'REQ-001', { + reason: 'Risk accepted', + expires: null, + manual_checklist: [], + approved_by: '', + }); + expect(mutate).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/web-ui/__tests__/app/proof/req_id/page.test.tsx b/web-ui/__tests__/app/proof/req_id/page.test.tsx new file mode 100644 index 00000000..dc4e9c23 --- /dev/null +++ b/web-ui/__tests__/app/proof/req_id/page.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ProofDetailPage from '@/app/proof/[req_id]/page'; +import { localStorageMock } from '../../../utils/test-helpers'; + +jest.mock('@/lib/api', () => ({ + proofApi: { + getRequirement: jest.fn(), + getEvidence: jest.fn(), + waive: jest.fn(), + }, +})); + +jest.mock('@/lib/workspace-storage', () => ({ + getSelectedWorkspacePath: jest.fn(() => '/test/workspace'), +})); + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({ req_id: 'REQ-001' })), + useRouter: jest.fn(() => ({ push: jest.fn() })), + usePathname: jest.fn(() => '/proof/REQ-001'), + useSearchParams: jest.fn(() => new URLSearchParams()), +})); + +jest.mock('swr', () => ({ __esModule: true, default: jest.fn() })); + +import useSWR from 'swr'; +import { proofApi } from '@/lib/api'; + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockWaive = proofApi.waive as jest.MockedFunction; + +const baseReq = { + id: 'REQ-001', + title: 'Login must work with MFA', + description: 'Ensure MFA flow is tested', + severity: 'high', + status: 'open', + glitch_type: 'regression', + obligations: [], + evidence_rules: [], + waiver: null, + created_at: '2026-01-15T10:00:00Z', + satisfied_at: null, + created_by: 'frank', + source_issue: null, + related_reqs: [], + source: 'manual', +}; + +const waivedReq = { + ...baseReq, + status: 'waived', + waiver: { + reason: 'MFA not in scope for this sprint', + expires: '2026-06-01', + manual_checklist: [], + approved_by: 'alice', + waived_at: '2026-03-10T09:00:00Z', + }, +}; + +const mockEvidenceResponse = []; + +describe('ProofDetailPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + }); + + const setupSWR = (req: typeof baseReq) => { + mockUseSWR.mockImplementation((key: any) => { + if (typeof key === 'string' && key.includes('/evidence')) { + return { data: mockEvidenceResponse, error: undefined, isLoading: false, mutate: jest.fn() } as any; + } + return { data: req, error: undefined, isLoading: false, mutate: jest.fn() } as any; + }); + }; + + describe('waiver audit trail', () => { + it('shows waiver reason in the waiver section', async () => { + setupSWR(waivedReq as any); + render(); + + await waitFor(() => { + expect(screen.getByText('MFA not in scope for this sprint')).toBeInTheDocument(); + }); + }); + + it('shows approved_by in the waiver section', async () => { + setupSWR(waivedReq as any); + render(); + + await waitFor(() => { + expect(screen.getByText(/alice/i)).toBeInTheDocument(); + }); + }); + + it('shows waived_at timestamp when present', async () => { + setupSWR(waivedReq as any); + render(); + + await waitFor(() => { + // The timestamp is formatted via toLocaleString or similar + expect(screen.getByText(/waived:/i)).toBeInTheDocument(); + }); + }); + + it('does not show waived_at section when absent', async () => { + const reqWithoutTimestamp = { + ...waivedReq, + waiver: { ...waivedReq.waiver, waived_at: undefined }, + }; + setupSWR(reqWithoutTimestamp as any); + render(); + + await waitFor(() => screen.getByText('MFA not in scope for this sprint')); + expect(screen.queryByText(/waived:/i)).not.toBeInTheDocument(); + }); + + it('shows "No waiver on file" when requirement is open', async () => { + setupSWR(baseReq); + render(); + + await waitFor(() => { + expect(screen.getByText(/no waiver on file/i)).toBeInTheDocument(); + }); + }); + }); + + describe('WaiveDialog — 2-step confirmation flow', () => { + it('opens form step when Waive button is clicked', async () => { + setupSWR(baseReq); + render(); + + await waitFor(() => screen.getByRole('button', { name: /waive this requirement/i })); + fireEvent.click(screen.getByRole('button', { name: /waive this requirement/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/reason/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); + }); + }); + + it('shows confirmation warning after filling reason', async () => { + setupSWR(baseReq); + render(); + + await waitFor(() => screen.getByRole('button', { name: /waive this requirement/i })); + fireEvent.click(screen.getByRole('button', { name: /waive this requirement/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Deferred to Q2' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => { + expect(screen.getByText(/marked satisfied without evidence/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm waive/i })).toBeInTheDocument(); + }); + }); + + it('submits waiver from confirmation step', async () => { + const mutate = jest.fn(); + mockUseSWR.mockImplementation((key: any) => { + if (typeof key === 'string' && key.includes('/evidence')) { + return { data: [], error: undefined, isLoading: false, mutate: jest.fn() } as any; + } + return { data: baseReq, error: undefined, isLoading: false, mutate } as any; + }); + mockWaive.mockResolvedValueOnce(undefined as any); + + render(); + await waitFor(() => screen.getByRole('button', { name: /waive this requirement/i })); + fireEvent.click(screen.getByRole('button', { name: /waive this requirement/i })); + + await waitFor(() => screen.getByLabelText(/reason/i)); + fireEvent.change(screen.getByLabelText(/reason/i), { + target: { value: 'Accepted risk' }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => screen.getByRole('button', { name: /confirm waive/i })); + fireEvent.click(screen.getByRole('button', { name: /confirm waive/i })); + + await waitFor(() => { + expect(mockWaive).toHaveBeenCalledWith('/test/workspace', 'REQ-001', { + reason: 'Accepted risk', + expires: null, + manual_checklist: [], + approved_by: '', + }); + expect(mutate).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/web-ui/__tests__/utils/test-helpers.ts b/web-ui/__tests__/utils/test-helpers.ts new file mode 100644 index 00000000..4fa81f0d --- /dev/null +++ b/web-ui/__tests__/utils/test-helpers.ts @@ -0,0 +1,13 @@ +/** Shared test utilities for the web-ui test suite. */ + +export const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); diff --git a/web-ui/src/app/proof/[req_id]/page.tsx b/web-ui/src/app/proof/[req_id]/page.tsx index 9df63cc2..99347f8d 100644 --- a/web-ui/src/app/proof/[req_id]/page.tsx +++ b/web-ui/src/app/proof/[req_id]/page.tsx @@ -4,109 +4,11 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import useSWR from 'swr'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { ProofStatusBadge } from '@/components/proof'; +import { ProofStatusBadge, WaiveDialog } from '@/components/proof'; import { proofApi } from '@/lib/api'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; -import type { ProofRequirement, ProofEvidence, WaiveRequest } from '@/types'; - -function WaiveDialog({ - reqId, - workspacePath, - onClose, - onSuccess, -}: { - reqId: string; - workspacePath: string; - onClose: () => void; - onSuccess: () => void; -}) { - const [reason, setReason] = useState(''); - const [expires, setExpires] = useState(''); - const [approvedBy, setApprovedBy] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!reason.trim()) { setError('Reason is required'); return; } - setSubmitting(true); - setError(null); - try { - const body: WaiveRequest = { - reason: reason.trim(), - expires: expires || null, - manual_checklist: [], - approved_by: approvedBy.trim(), - }; - await proofApi.waive(workspacePath, reqId, body); - onSuccess(); - } catch { - setError('Failed to waive requirement'); - } finally { - setSubmitting(false); - } - }; - - return ( - { if (!open) onClose(); }}> - - - Waive {reqId} - -
-
- -