Skip to content

Commit 796e9c3

Browse files
committed
feat: codify submission identity policy rules with tests
1 parent 1355060 commit 796e9c3

5 files changed

Lines changed: 180 additions & 29 deletions

File tree

docs/plan/M04-public-commenting-workflow.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ Deliver complete public submission workflow with configurable identity policy.
2121

2222
## Acceptance criteria
2323

24-
- [ ] Public can submit according to docket policy.
25-
- [ ] Submitted comments enter moderation correctly.
24+
- [x] Public can submit according to docket policy.
25+
- [x] Submitted comments enter moderation correctly.
2626

2727
## Risks/blockers
2828

@@ -39,3 +39,4 @@ Deliver complete public submission workflow with configurable identity policy.
3939
- 2026-02-11: Added edge function `supabase/functions/submit-comment/index.ts` for server-side comment submission with hCaptcha verification, identity-mode enforcement, per-docket limits, IP rate limiting, and deterministic status assignment.
4040
- 2026-02-11: Updated `src/pages/public/CommentWizard.tsx` to submit through server function, enforce per-docket identity requirements in UX/validation, and support authenticated-required dockets without hardcoded global login redirects.
4141
- 2026-02-11: Hardened attachment lifecycle in `src/pages/public/CommentWizard.tsx` by removing uploaded files from storage when metadata insert fails, preventing orphaned attachment objects.
42+
- 2026-02-11: Extracted identity/status policy logic into `supabase/functions/_shared/submissionPolicy.ts` and added `tests/submission-policy.test.ts` to validate docket identity modes and deterministic moderation-entry status behavior.

docs/plan/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ This folder tracks implementation progress for the OpenComments production roadm
3333

3434
- `M00`: Completed
3535
- `M01`: In progress
36-
- `M02`: In progress
36+
- `M02`: Completed
3737
- `M03`: Completed
38-
- `M04`: In progress
38+
- `M04`: Completed
3939
- `M05`: Completed
4040
- `M06`: Completed
4141
- `M07`: In progress
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export type IdentityMode = 'optional' | 'name_required' | 'name_email_required' | 'authenticated'
2+
3+
interface IdentityValidationInput {
4+
identityMode: IdentityMode
5+
isAuthenticated: boolean
6+
commenterName: string | null
7+
commenterEmail: string | null
8+
}
9+
10+
export interface IdentityValidationResult {
11+
ok: boolean
12+
status: number
13+
error?: string
14+
abuseEventType?: string
15+
}
16+
17+
export function isValidEmail(email: string): boolean {
18+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
19+
}
20+
21+
export function validateIdentityRequirements(input: IdentityValidationInput): IdentityValidationResult {
22+
const { identityMode, isAuthenticated, commenterName, commenterEmail } = input
23+
24+
if (identityMode === 'authenticated' && !isAuthenticated) {
25+
return {
26+
ok: false,
27+
status: 401,
28+
error: 'Authentication is required for this docket',
29+
abuseEventType: 'identity_auth_required'
30+
}
31+
}
32+
33+
if ((identityMode === 'name_required' || identityMode === 'name_email_required') && !commenterName) {
34+
return {
35+
ok: false,
36+
status: 400,
37+
error: 'Name is required for this docket'
38+
}
39+
}
40+
41+
if (identityMode === 'name_email_required' && !commenterEmail) {
42+
return {
43+
ok: false,
44+
status: 400,
45+
error: 'Email is required for this docket'
46+
}
47+
}
48+
49+
if (commenterEmail && !isValidEmail(commenterEmail)) {
50+
return {
51+
ok: false,
52+
status: 400,
53+
error: 'A valid email address is required'
54+
}
55+
}
56+
57+
return {
58+
ok: true,
59+
status: 200
60+
}
61+
}
62+
63+
export function determineSubmissionStatus(autoPublish: boolean): 'published' | 'submitted' {
64+
return autoPublish ? 'published' : 'submitted'
65+
}

supabase/functions/submit-comment/index.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
22
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3+
import {
4+
type IdentityMode,
5+
validateIdentityRequirements,
6+
determineSubmissionStatus
7+
} from '../_shared/submissionPolicy.ts'
38

49
const corsHeaders = {
510
'Access-Control-Allow-Origin': '*',
611
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
712
'Access-Control-Allow-Methods': 'POST,OPTIONS'
813
}
914

10-
type IdentityMode = 'optional' | 'name_required' | 'name_email_required' | 'authenticated'
11-
1215
interface SubmissionRequest {
1316
docket_slug?: string
1417
docket_id?: string
@@ -61,10 +64,6 @@ function normalizeText(value?: string): string | null {
6164
return text
6265
}
6366

64-
function isValidEmail(email: string): boolean {
65-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
66-
}
67-
6867
function toHex(buffer: ArrayBuffer): string {
6968
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('')
7069
}
@@ -232,24 +231,23 @@ serve(async (req) => {
232231
}
233232

234233
const identityMode: IdentityMode = (docket.identity_mode as IdentityMode) || 'optional'
235-
if (identityMode === 'authenticated' && !user) {
236-
await logAbuseEvent(serviceClient, {
237-
agencyId: docket.agency_id,
238-
docketId: docket.id,
239-
ipAddress,
240-
eventType: 'identity_auth_required',
241-
details: { identity_mode: identityMode }
242-
})
243-
return jsonResponse({ error: 'Authentication is required for this docket' }, 401)
244-
}
245-
if ((identityMode === 'name_required' || identityMode === 'name_email_required') && !commenterName) {
246-
return jsonResponse({ error: 'Name is required for this docket' }, 400)
247-
}
248-
if (identityMode === 'name_email_required' && !commenterEmail) {
249-
return jsonResponse({ error: 'Email is required for this docket' }, 400)
250-
}
251-
if (commenterEmail && !isValidEmail(commenterEmail)) {
252-
return jsonResponse({ error: 'A valid email address is required' }, 400)
234+
const identityValidation = validateIdentityRequirements({
235+
identityMode,
236+
isAuthenticated: Boolean(user),
237+
commenterName,
238+
commenterEmail
239+
})
240+
if (!identityValidation.ok) {
241+
if (identityValidation.abuseEventType) {
242+
await logAbuseEvent(serviceClient, {
243+
agencyId: docket.agency_id,
244+
docketId: docket.id,
245+
ipAddress,
246+
eventType: identityValidation.abuseEventType,
247+
details: { identity_mode: identityMode }
248+
})
249+
}
250+
return jsonResponse({ error: identityValidation.error }, identityValidation.status)
253251
}
254252

255253
const maxLength = docket.max_comment_length || 4000
@@ -348,7 +346,7 @@ serve(async (req) => {
348346
}
349347

350348
const trackingId = `CMT-${Date.now().toString(36).toUpperCase()}-${crypto.randomUUID().slice(0, 6).toUpperCase()}`
351-
const status = docket.auto_publish ? 'published' : 'submitted'
349+
const status = determineSubmissionStatus(Boolean(docket.auto_publish))
352350
const contentHash = await hashContent(content)
353351

354352
const { data: insertedComment, error: insertError } = await serviceClient

tests/submission-policy.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
determineSubmissionStatus,
4+
validateIdentityRequirements
5+
} from '../supabase/functions/_shared/submissionPolicy'
6+
7+
describe('submission policy', () => {
8+
it('accepts optional identity with no user profile fields', () => {
9+
const result = validateIdentityRequirements({
10+
identityMode: 'optional',
11+
isAuthenticated: false,
12+
commenterName: null,
13+
commenterEmail: null
14+
})
15+
16+
expect(result.ok).toBe(true)
17+
})
18+
19+
it('requires authentication when docket mode is authenticated', () => {
20+
const result = validateIdentityRequirements({
21+
identityMode: 'authenticated',
22+
isAuthenticated: false,
23+
commenterName: null,
24+
commenterEmail: null
25+
})
26+
27+
expect(result).toEqual({
28+
ok: false,
29+
status: 401,
30+
error: 'Authentication is required for this docket',
31+
abuseEventType: 'identity_auth_required'
32+
})
33+
})
34+
35+
it('requires name when docket mode requires name', () => {
36+
const result = validateIdentityRequirements({
37+
identityMode: 'name_required',
38+
isAuthenticated: false,
39+
commenterName: null,
40+
commenterEmail: null
41+
})
42+
43+
expect(result).toEqual({
44+
ok: false,
45+
status: 400,
46+
error: 'Name is required for this docket'
47+
})
48+
})
49+
50+
it('requires email when docket mode requires name and email', () => {
51+
const result = validateIdentityRequirements({
52+
identityMode: 'name_email_required',
53+
isAuthenticated: false,
54+
commenterName: 'Jane Smith',
55+
commenterEmail: null
56+
})
57+
58+
expect(result).toEqual({
59+
ok: false,
60+
status: 400,
61+
error: 'Email is required for this docket'
62+
})
63+
})
64+
65+
it('rejects invalid email formatting', () => {
66+
const result = validateIdentityRequirements({
67+
identityMode: 'optional',
68+
isAuthenticated: false,
69+
commenterName: 'Jane Smith',
70+
commenterEmail: 'not-an-email'
71+
})
72+
73+
expect(result).toEqual({
74+
ok: false,
75+
status: 400,
76+
error: 'A valid email address is required'
77+
})
78+
})
79+
80+
it('returns published status when auto-publish is enabled', () => {
81+
expect(determineSubmissionStatus(true)).toBe('published')
82+
})
83+
84+
it('returns submitted status when auto-publish is disabled', () => {
85+
expect(determineSubmissionStatus(false)).toBe('submitted')
86+
})
87+
})

0 commit comments

Comments
 (0)