Skip to content

Commit b476ce1

Browse files
authored
DC-48(feat): Add id-proofing form, route, API hook, and mock handler (#51)
* DC-48(feat): Add id-proofing form, route, API hook, and mock handler * DC-48(feat): Harden id-proofing validation and wire requiresIdProofing routing - Add idType required validation to IdProofingForm; show error when no option selected - Add superRefine to SubmitIdProofingRequestSchema enforcing idType/idValue consistency - Add requiresIdProofing field to ValidateOtpResponse schema; route post-OTP to id-proofing only when the flag is true, otherwise proceed to dashboard - Pass nonce to GoogleAnalytics for CSP strict-dynamic compliance - Expand IdProofingForm tests: fix error counts, add MSW-backed API error test, add idType-required test * DC-48(test): Cover requiresIdProofing=false and absent routing paths Both the false and absent cases route to /dashboard via the strict === true guard, but neither path had test coverage. Adds two new email fixtures to the MSW handler to exercise each scenario. * DC-48(review): Address PR #51 review feedback and add hook test
1 parent 639ba9c commit b476ce1

16 files changed

Lines changed: 1149 additions & 8 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { IdProofingForm, type IdOption } from '@/features/auth'
2+
import { getStateLinks } from '@/lib/links'
3+
import { getState } from '@/lib/state'
4+
import { getTranslations } from '@/lib/translations'
5+
6+
// DC-only: CO uses external auth and never reaches this route.
7+
// If a future state adopts OTP auth with id-proofing, add a state-based options
8+
// map or guard here.
9+
//
10+
// The full set shown here is for non-co-loaded users. Once the backend confirms
11+
// which options are available for co-loaded users (via JWT claim), this list can
12+
// be made dynamic at the page level.
13+
// TODO: Filter options based on co-loaded status once that claim is available in the JWT.
14+
const DC_ID_OPTIONS: IdOption[] = [
15+
{
16+
value: 'ssn',
17+
labelKey: 'optionLabelSsn',
18+
inputLabelKey: 'labelSsn'
19+
},
20+
{
21+
value: 'itin',
22+
labelKey: 'optionLabelItin',
23+
inputLabelKey: 'labelItin'
24+
},
25+
{
26+
value: 'medicaidId',
27+
labelKey: 'optionLabelMedicaidId',
28+
helperKey: 'optionHelperMedicaidId',
29+
inputLabelKey: 'labelMedicaidId'
30+
},
31+
{
32+
value: 'snapAccountId',
33+
labelKey: 'optionAccountId',
34+
helperKey: 'optionHelperAccountId',
35+
inputLabelKey: 'labelAccountId'
36+
},
37+
{
38+
value: 'snapPersonId',
39+
labelKey: 'optionPersonId',
40+
helperKey: 'optionHelperPersonId',
41+
inputLabelKey: 'labelPersonId'
42+
},
43+
{
44+
value: 'none',
45+
// TODO: Use t('optionLabelNone') once key is available in dc.csv
46+
labelKey: 'optionLabelNone'
47+
}
48+
]
49+
50+
export default function IdProofingPage() {
51+
const state = getState()
52+
const links = getStateLinks(state)
53+
const t = getTranslations('idProofing')
54+
55+
return (
56+
<div className="usa-section">
57+
<div className="grid-container maxw-tablet">
58+
<section aria-labelledby="id-proofing-title">
59+
<h1
60+
id="id-proofing-title"
61+
className="font-sans-xl text-bold line-height-sans-1 margin-bottom-3"
62+
>
63+
{t('title')}
64+
</h1>
65+
66+
<p className="margin-top-0 font-sans-sm">{t('body')}</p>
67+
68+
{/* TODO: Use t('requiredDisclaimer') once key is available in dc.csv */}
69+
<p className="margin-top-2 font-sans-sm">Asterisks (*) indicate a required field.</p>
70+
71+
<IdProofingForm
72+
idOptions={DC_ID_OPTIONS}
73+
contactLink={links.external.contactUsAssistance}
74+
/>
75+
</section>
76+
</div>
77+
</div>
78+
)
79+
}

src/SEBT.Portal.Web/src/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ export default async function RootLayout({
113113
/>
114114
</body>
115115
{/* Google Analytics - only rendered when GA_ID is configured */}
116-
{gaId && <GoogleAnalytics gaId={gaId} />}
116+
{/* nonce is required for CSP compliance: proxy.ts enforces nonce-based strict-dynamic */}
117+
{gaId && (
118+
<GoogleAnalytics
119+
gaId={gaId}
120+
{...(nonce ? { nonce } : {})}
121+
/>
122+
)}
117123
</html>
118124
)
119125
}

src/SEBT.Portal.Web/src/features/auth/api/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
export { useRefreshToken } from './refresh-token'
22

3+
export {
4+
IdTypeSchema,
5+
SubmitIdProofingRequestSchema,
6+
useSubmitIdProofing,
7+
type IdType,
8+
type SubmitIdProofingRequest
9+
} from './submit-id-proofing'
10+
311
export { RequestOtpRequestSchema, useRequestOtp, type RequestOtpRequest } from './request-otp'
412

513
export {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export {
2+
IdTypeSchema,
3+
SubmitIdProofingRequestSchema,
4+
type IdType,
5+
type SubmitIdProofingRequest
6+
} from './schema'
7+
export { useSubmitIdProofing } from './useSubmitIdProofing'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { z } from 'zod'
2+
3+
// The ID types the user can provide for identity proofing.
4+
// 'none' is a UI-only sentinel — the API receives null when the user selects "none of the above".
5+
export const IdTypeSchema = z.enum(['snapAccountId', 'snapPersonId', 'medicaidId', 'ssn', 'itin'])
6+
export type IdType = z.infer<typeof IdTypeSchema>
7+
8+
export const SubmitIdProofingRequestSchema = z
9+
.object({
10+
dateOfBirth: z.object({
11+
month: z.string(),
12+
day: z.string(),
13+
year: z.string()
14+
}),
15+
// null when the user selects "none of the above"
16+
idType: IdTypeSchema.nullable(),
17+
// null when idType is null
18+
idValue: z.string().nullable()
19+
})
20+
.superRefine((data, ctx) => {
21+
if (data.idType === null && data.idValue !== null) {
22+
ctx.addIssue({
23+
code: z.ZodIssueCode.custom,
24+
path: ['idValue'],
25+
message: 'idValue must be null when idType is null'
26+
})
27+
}
28+
if (data.idType !== null) {
29+
const v = data.idValue
30+
if (v === null || v.trim() === '') {
31+
ctx.addIssue({
32+
code: z.ZodIssueCode.custom,
33+
path: ['idValue'],
34+
message: 'idValue is required when idType is not null'
35+
})
36+
}
37+
}
38+
})
39+
40+
export type SubmitIdProofingRequest = z.infer<typeof SubmitIdProofingRequestSchema>
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* useSubmitIdProofing Hook Unit Tests
3+
*
4+
* Tests the id-proofing mutation hook behavior including:
5+
* - Successful submission (204 No Content)
6+
* - Correct payload sent to the endpoint
7+
* - Error handling for 4xx errors (no retry)
8+
* - Retry behavior for 5xx errors
9+
*/
10+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
11+
import { renderHook, waitFor } from '@testing-library/react'
12+
import { http, HttpResponse } from 'msw'
13+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14+
15+
import { server } from '@/mocks/server'
16+
17+
import type { SubmitIdProofingRequest } from './schema'
18+
import { useSubmitIdProofing } from './useSubmitIdProofing'
19+
20+
// Obviously fake PII values per CLAUDE.md PII conventions
21+
const VALID_PAYLOAD: SubmitIdProofingRequest = {
22+
dateOfBirth: { month: '01', day: '15', year: '1990' },
23+
idType: 'ssn',
24+
idValue: '999999999'
25+
}
26+
27+
function createWrapper() {
28+
const queryClient = new QueryClient({
29+
defaultOptions: {
30+
queries: { retry: false }
31+
}
32+
})
33+
return function Wrapper({ children }: { children: React.ReactNode }) {
34+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
35+
}
36+
}
37+
38+
describe('useSubmitIdProofing', () => {
39+
beforeEach(() => {
40+
vi.useFakeTimers({ shouldAdvanceTime: true })
41+
})
42+
43+
afterEach(() => {
44+
vi.useRealTimers()
45+
})
46+
47+
describe('Successful Submission', () => {
48+
it('should succeed on valid id-proofing submission', async () => {
49+
const { result } = renderHook(() => useSubmitIdProofing(), {
50+
wrapper: createWrapper()
51+
})
52+
53+
result.current.mutate(VALID_PAYLOAD)
54+
55+
await waitFor(() => {
56+
expect(result.current.isSuccess).toBe(true)
57+
})
58+
})
59+
60+
it('should send the correct payload to the endpoint', async () => {
61+
let capturedBody: SubmitIdProofingRequest | null = null
62+
63+
server.use(
64+
http.post('/api/id-proofing', async ({ request }) => {
65+
capturedBody = (await request.json()) as SubmitIdProofingRequest
66+
return HttpResponse.json(null, { status: 204 })
67+
})
68+
)
69+
70+
const { result } = renderHook(() => useSubmitIdProofing(), {
71+
wrapper: createWrapper()
72+
})
73+
74+
result.current.mutate(VALID_PAYLOAD)
75+
76+
await waitFor(() => {
77+
expect(result.current.isSuccess).toBe(true)
78+
})
79+
80+
expect(capturedBody).toEqual(VALID_PAYLOAD)
81+
})
82+
})
83+
84+
describe('Error Handling', () => {
85+
it('should NOT retry on 400 bad request', async () => {
86+
let requestCount = 0
87+
88+
server.use(
89+
http.post('/api/id-proofing', () => {
90+
requestCount++
91+
return HttpResponse.json({ error: 'Bad Request' }, { status: 400 })
92+
})
93+
)
94+
95+
const queryClient = new QueryClient()
96+
97+
const { result } = renderHook(() => useSubmitIdProofing(), {
98+
wrapper: ({ children }) => (
99+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
100+
)
101+
})
102+
103+
result.current.mutate(VALID_PAYLOAD)
104+
105+
await waitFor(() => {
106+
expect(result.current.isError).toBe(true)
107+
})
108+
109+
// Should only make 1 request - no retries for 4xx
110+
expect(requestCount).toBe(1)
111+
})
112+
113+
it('should NOT retry on 401 unauthorized', async () => {
114+
let requestCount = 0
115+
116+
server.use(
117+
http.post('/api/id-proofing', () => {
118+
requestCount++
119+
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
120+
})
121+
)
122+
123+
const queryClient = new QueryClient()
124+
125+
const { result } = renderHook(() => useSubmitIdProofing(), {
126+
wrapper: ({ children }) => (
127+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
128+
)
129+
})
130+
131+
result.current.mutate(VALID_PAYLOAD)
132+
133+
await waitFor(() => {
134+
expect(result.current.isError).toBe(true)
135+
})
136+
137+
expect(requestCount).toBe(1)
138+
})
139+
})
140+
141+
describe('Retry Logic', () => {
142+
it('should retry on 5xx server errors up to 2 times', async () => {
143+
let requestCount = 0
144+
145+
server.use(
146+
http.post('/api/id-proofing', () => {
147+
requestCount++
148+
return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
149+
})
150+
)
151+
152+
const queryClient = new QueryClient()
153+
154+
const { result } = renderHook(() => useSubmitIdProofing(), {
155+
wrapper: ({ children }) => (
156+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
157+
)
158+
})
159+
160+
result.current.mutate(VALID_PAYLOAD)
161+
162+
// Advance timers to allow retries with exponential backoff
163+
await vi.advanceTimersByTimeAsync(1000) // First retry delay
164+
await vi.advanceTimersByTimeAsync(2000) // Second retry delay
165+
await vi.advanceTimersByTimeAsync(4000) // Extra time for processing
166+
167+
await waitFor(() => {
168+
expect(result.current.isError).toBe(true)
169+
})
170+
171+
// Should make 3 requests total: initial + 2 retries
172+
expect(requestCount).toBe(3)
173+
})
174+
175+
it('should succeed on retry after transient server error', async () => {
176+
let requestCount = 0
177+
178+
server.use(
179+
http.post('/api/id-proofing', () => {
180+
requestCount++
181+
if (requestCount === 1) {
182+
return HttpResponse.json({ error: 'Service Unavailable' }, { status: 503 })
183+
}
184+
return HttpResponse.json(null, { status: 204 })
185+
})
186+
)
187+
188+
const queryClient = new QueryClient()
189+
190+
const { result } = renderHook(() => useSubmitIdProofing(), {
191+
wrapper: ({ children }) => (
192+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
193+
)
194+
})
195+
196+
result.current.mutate(VALID_PAYLOAD)
197+
198+
// Advance timer for retry
199+
await vi.advanceTimersByTimeAsync(1000)
200+
201+
await waitFor(() => {
202+
expect(result.current.isSuccess).toBe(true)
203+
})
204+
205+
expect(requestCount).toBe(2)
206+
})
207+
})
208+
})

0 commit comments

Comments
 (0)