Skip to content

Commit 10ec571

Browse files
frankbriaTest User
andauthored
feat(web-ui): waiver confirmation + audit trail for Proof page (#479)
* feat(web-ui): waiver confirmation dialog + audit trail for Proof page (#479) - 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 * fix: address CodeRabbit feedback on PR #521 - Add waived_at to backend Waiver dataclass, ledger serialization, and WaiverOut model so the API returns the timestamp when requirements are waived; waive endpoint sets waived_at = datetime.now(UTC) - Extract shared WaiveDialog to src/components/proof/WaiveDialog.tsx; both proof/page.tsx and proof/[req_id]/page.tsx now import the single implementation instead of duplicating it - Extract localStorageMock to __tests__/utils/test-helpers.ts and import from both proof test files to eliminate duplication - Strengthen WaiveRequest assertion in tests to validate all payload fields * fix: move waived_at timestamping from router to core waive_requirement Per thin-adapter pattern, business logic (setting waived_at) belongs in core not in the HTTP router. waive_requirement() in ledger.py now stamps waived_at = datetime.now(UTC) when not already set, ensuring all entry points (API, CLI) consistently record the audit timestamp. --------- Co-authored-by: Test User <test@example.com>
1 parent a4bda97 commit 10ec571

12 files changed

Lines changed: 599 additions & 210 deletions

File tree

codeframe/core/proof/ledger.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,21 @@ def _waiver_to_json(waiver: Optional[Waiver]) -> Optional[str]:
137137
"expires": waiver.expires.isoformat() if waiver.expires else None,
138138
"manual_checklist": waiver.manual_checklist,
139139
"approved_by": waiver.approved_by,
140+
"waived_at": waiver.waived_at.isoformat() if waiver.waived_at else None,
140141
})
141142

142143

143144
def _waiver_from_json(raw: Optional[str]) -> Optional[Waiver]:
144145
if not raw:
145146
return None
146147
data = json.loads(raw)
148+
waived_at_raw = data.get("waived_at")
147149
return Waiver(
148150
reason=data["reason"],
149151
expires=date.fromisoformat(data["expires"]) if data.get("expires") else None,
150152
manual_checklist=data.get("manual_checklist", []),
151153
approved_by=data.get("approved_by", ""),
154+
waived_at=datetime.fromisoformat(waived_at_raw) if waived_at_raw else None,
152155
)
153156

154157

@@ -317,6 +320,14 @@ def waive_requirement(
317320
workspace: Workspace, req_id: str, waiver: Waiver
318321
) -> Optional[Requirement]:
319322
"""Waive a requirement with reason and optional expiry."""
323+
if waiver.waived_at is None:
324+
waiver = Waiver(
325+
reason=waiver.reason,
326+
expires=waiver.expires,
327+
manual_checklist=waiver.manual_checklist,
328+
approved_by=waiver.approved_by,
329+
waived_at=datetime.now(timezone.utc),
330+
)
320331
_ensure_tables(workspace)
321332
conn = get_db_connection(workspace)
322333
cursor = conn.cursor()

codeframe/core/proof/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class Waiver:
9898
expires: Optional[date] = None
9999
manual_checklist: list[str] = field(default_factory=list)
100100
approved_by: str = ""
101+
waived_at: Optional[datetime] = None
101102

102103

103104
@dataclass

codeframe/ui/routers/proof_v2.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class WaiverOut(BaseModel):
100100
expires: Optional[str]
101101
manual_checklist: list[str]
102102
approved_by: str
103+
waived_at: Optional[str] = None
103104

104105

105106
class RequirementResponse(BaseModel):
@@ -195,6 +196,7 @@ def _req_to_response(req) -> RequirementResponse:
195196
expires=req.waiver.expires.isoformat() if req.waiver.expires else None,
196197
manual_checklist=req.waiver.manual_checklist,
197198
approved_by=req.waiver.approved_by,
199+
waived_at=req.waiver.waived_at.isoformat() if req.waiver.waived_at else None,
198200
) if req.waiver else None,
199201
created_at=req.created_at.isoformat() if req.created_at else None,
200202
satisfied_at=req.satisfied_at.isoformat() if req.satisfied_at else None,

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,6 @@ module.exports = {
6060
Alert01Icon: createIconMock('Alert01Icon'),
6161
// SplitPane
6262
ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'),
63+
// Proof page
64+
InformationCircleIcon: createIconMock('InformationCircleIcon'),
6365
};
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import ProofPage from '@/app/proof/page';
3+
import { localStorageMock } from '../../utils/test-helpers';
4+
5+
jest.mock('@/lib/api', () => ({
6+
proofApi: {
7+
listRequirements: jest.fn(),
8+
waive: jest.fn(),
9+
},
10+
}));
11+
12+
jest.mock('@/lib/workspace-storage', () => ({
13+
getSelectedWorkspacePath: jest.fn(() => '/test/workspace'),
14+
}));
15+
16+
jest.mock('swr', () => ({ __esModule: true, default: jest.fn() }));
17+
18+
import useSWR from 'swr';
19+
import { proofApi } from '@/lib/api';
20+
21+
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
22+
const mockWaive = proofApi.waive as jest.MockedFunction<typeof proofApi.waive>;
23+
24+
const openReq = {
25+
id: 'REQ-001',
26+
title: 'Test requirement',
27+
description: 'A test requirement',
28+
severity: 'high',
29+
status: 'open',
30+
glitch_type: 'regression',
31+
obligations: [],
32+
evidence_rules: [],
33+
waiver: null,
34+
created_at: '2026-01-01T00:00:00Z',
35+
satisfied_at: null,
36+
created_by: 'tester',
37+
source_issue: null,
38+
related_reqs: [],
39+
source: 'manual',
40+
};
41+
42+
const waivedReq = {
43+
...openReq,
44+
id: 'REQ-002',
45+
title: 'Waived requirement',
46+
status: 'waived',
47+
waiver: {
48+
reason: 'Not applicable for this release',
49+
expires: null,
50+
manual_checklist: [],
51+
approved_by: 'frank',
52+
waived_at: '2026-03-01T12:00:00Z',
53+
},
54+
};
55+
56+
describe('ProofPage', () => {
57+
beforeEach(() => {
58+
jest.clearAllMocks();
59+
localStorageMock.clear();
60+
mockUseSWR.mockReturnValue({
61+
data: {
62+
requirements: [openReq, waivedReq],
63+
total: 2,
64+
by_status: { open: 1, waived: 1, satisfied: 0 },
65+
},
66+
error: undefined,
67+
isLoading: false,
68+
mutate: jest.fn(),
69+
} as any);
70+
});
71+
72+
describe('waived row visual treatment', () => {
73+
it('renders waived rows with muted/strikethrough styling', async () => {
74+
render(<ProofPage />);
75+
await waitFor(() => screen.getByText('Waived requirement'));
76+
77+
const waivedRow = screen.getByText('Waived requirement').closest('tr');
78+
expect(waivedRow).toHaveClass('opacity-60');
79+
});
80+
81+
it('does not apply muted styling to open rows', async () => {
82+
render(<ProofPage />);
83+
await waitFor(() => screen.getByText('Test requirement'));
84+
85+
const openRow = screen.getByText('Test requirement').closest('tr');
86+
expect(openRow).not.toHaveClass('opacity-60');
87+
});
88+
89+
it('does not show Waive button for waived requirements', async () => {
90+
render(<ProofPage />);
91+
await waitFor(() => screen.getByText('Waived requirement'));
92+
93+
const buttons = screen.getAllByRole('button', { name: /waive/i });
94+
// Only one Waive button for the open req
95+
expect(buttons).toHaveLength(1);
96+
});
97+
});
98+
99+
describe('WaiveDialog — 2-step confirmation flow', () => {
100+
it('opens the form step when Waive is clicked', async () => {
101+
render(<ProofPage />);
102+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
103+
104+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
105+
106+
await waitFor(() => {
107+
expect(screen.getByText(/waive req-001/i)).toBeInTheDocument();
108+
expect(screen.getByLabelText(/reason/i)).toBeInTheDocument();
109+
expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument();
110+
});
111+
});
112+
113+
it('shows error if Continue is clicked without a reason', async () => {
114+
render(<ProofPage />);
115+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
116+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
117+
118+
await waitFor(() => screen.getByRole('button', { name: /continue/i }));
119+
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
120+
121+
await waitFor(() => {
122+
expect(screen.getByText(/reason is required/i)).toBeInTheDocument();
123+
});
124+
});
125+
126+
it('advances to confirmation step when reason is provided', async () => {
127+
render(<ProofPage />);
128+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
129+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
130+
131+
await waitFor(() => screen.getByLabelText(/reason/i));
132+
fireEvent.change(screen.getByLabelText(/reason/i), {
133+
target: { value: 'Not needed this cycle' },
134+
});
135+
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
136+
137+
await waitFor(() => {
138+
expect(screen.getByText(/marked satisfied without evidence/i)).toBeInTheDocument();
139+
expect(screen.getByRole('button', { name: /confirm waive/i })).toBeInTheDocument();
140+
expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument();
141+
});
142+
});
143+
144+
it('shows the entered reason in the confirmation summary', async () => {
145+
render(<ProofPage />);
146+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
147+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
148+
149+
await waitFor(() => screen.getByLabelText(/reason/i));
150+
fireEvent.change(screen.getByLabelText(/reason/i), {
151+
target: { value: 'Accepted risk for v1' },
152+
});
153+
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
154+
155+
await waitFor(() => {
156+
expect(screen.getByText('Accepted risk for v1')).toBeInTheDocument();
157+
});
158+
});
159+
160+
it('goes back to form when Back is clicked', async () => {
161+
render(<ProofPage />);
162+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
163+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
164+
165+
await waitFor(() => screen.getByLabelText(/reason/i));
166+
fireEvent.change(screen.getByLabelText(/reason/i), {
167+
target: { value: 'Temporary waiver' },
168+
});
169+
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
170+
171+
await waitFor(() => screen.getByRole('button', { name: /back/i }));
172+
fireEvent.click(screen.getByRole('button', { name: /back/i }));
173+
174+
await waitFor(() => {
175+
expect(screen.getByLabelText(/reason/i)).toBeInTheDocument();
176+
expect(screen.getByDisplayValue('Temporary waiver')).toBeInTheDocument();
177+
});
178+
});
179+
180+
it('calls proofApi.waive and closes on Confirm Waive', async () => {
181+
mockWaive.mockResolvedValueOnce(undefined as any);
182+
const mutate = jest.fn();
183+
mockUseSWR.mockReturnValue({
184+
data: {
185+
requirements: [openReq, waivedReq],
186+
total: 2,
187+
by_status: { open: 1, waived: 1 },
188+
},
189+
error: undefined,
190+
isLoading: false,
191+
mutate,
192+
} as any);
193+
194+
render(<ProofPage />);
195+
await waitFor(() => screen.getByRole('button', { name: /^waive$/i }));
196+
fireEvent.click(screen.getByRole('button', { name: /^waive$/i }));
197+
198+
await waitFor(() => screen.getByLabelText(/reason/i));
199+
fireEvent.change(screen.getByLabelText(/reason/i), {
200+
target: { value: 'Risk accepted' },
201+
});
202+
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
203+
204+
await waitFor(() => screen.getByRole('button', { name: /confirm waive/i }));
205+
fireEvent.click(screen.getByRole('button', { name: /confirm waive/i }));
206+
207+
await waitFor(() => {
208+
expect(mockWaive).toHaveBeenCalledWith('/test/workspace', 'REQ-001', {
209+
reason: 'Risk accepted',
210+
expires: null,
211+
manual_checklist: [],
212+
approved_by: '',
213+
});
214+
expect(mutate).toHaveBeenCalled();
215+
});
216+
});
217+
});
218+
});

0 commit comments

Comments
 (0)