From 7cf845bdbafe0ca066443f548cf011823f93447b Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 2 Apr 2026 09:28:49 -0700 Subject: [PATCH 1/3] feat(web-ui): waiver confirmation dialog + audit trail for Proof page (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WaiveDialog converted to 2-step flow: form → amber warning confirmation before submitting in both proof/page.tsx and proof/[req_id]/page.tsx - Confirmation step shows compliance warning and summary of entered data - Back button returns to form with values preserved - Add waived_at?: string | null to ProofWaiver type; detail page shows waiver timestamp when present (reason, approved_by, waived_at, expires) - Waived rows in requirements list styled with opacity-60 for distinct visual treatment from open/satisfied requirements - Add InformationCircleIcon to @hugeicons/react mock - 17 new tests covering all acceptance criteria --- web-ui/__mocks__/@hugeicons/react.js | 2 + web-ui/__tests__/app/proof/page.test.tsx | 226 ++++++++++++++++++ .../__tests__/app/proof/req_id/page.test.tsx | 205 ++++++++++++++++ web-ui/src/app/proof/[req_id]/page.tsx | 125 ++++++---- web-ui/src/app/proof/page.tsx | 118 +++++---- web-ui/src/types/index.ts | 1 + 6 files changed, 593 insertions(+), 84 deletions(-) create mode 100644 web-ui/__tests__/app/proof/page.test.tsx create mode 100644 web-ui/__tests__/app/proof/req_id/page.test.tsx 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..b00e7b2e --- /dev/null +++ b/web-ui/__tests__/app/proof/page.test.tsx @@ -0,0 +1,226 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ProofPage from '@/app/proof/page'; + +// Mock localStorage +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 }); + +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', expect.objectContaining({ + reason: 'Risk accepted', + })); + 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..fdd2b6bc --- /dev/null +++ b/web-ui/__tests__/app/proof/req_id/page.test.tsx @@ -0,0 +1,205 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ProofDetailPage from '@/app/proof/[req_id]/page'; + +// Mock localStorage +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 }); + +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', expect.objectContaining({ + reason: 'Accepted risk', + })); + expect(mutate).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/web-ui/src/app/proof/[req_id]/page.tsx b/web-ui/src/app/proof/[req_id]/page.tsx index 9df63cc2..87caf280 100644 --- a/web-ui/src/app/proof/[req_id]/page.tsx +++ b/web-ui/src/app/proof/[req_id]/page.tsx @@ -30,15 +30,20 @@ function WaiveDialog({ onClose: () => void; onSuccess: () => void; }) { + const [step, setStep] = useState<'form' | 'confirm'>('form'); 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(); + const handleContinue = () => { if (!reason.trim()) { setError('Reason is required'); return; } + setError(null); + setStep('confirm'); + }; + + const handleConfirm = async () => { setSubmitting(true); setError(null); try { @@ -52,6 +57,7 @@ function WaiveDialog({ onSuccess(); } catch { setError('Failed to waive requirement'); + setStep('form'); } finally { setSubmitting(false); } @@ -63,46 +69,76 @@ function WaiveDialog({ Waive {reqId} -
-
- -