-
Notifications
You must be signed in to change notification settings - Fork 0
feat(erasure): Phase 8C — RA 10173 Erasure & Anonymization #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
3b5f3a8
feat(erasure): requestDataErasure callable with sentinel idempotency
Exc1D 4c8bf9b
fix(erasure): approve deny path — Auth re-enable, sentinel delete, pe…
Exc1D 2fe2474
feat(erasure): setErasureLegalHold callable — superadmin pause/resume…
Exc1D b7b4e5c
feat(erasure): erasureSweep — sequential claim, all-or-nothing anonym…
Exc1D f381ee5
feat(erasure): retentionSweep — 1-week anonymize, 1-month hard-delete…
Exc1D f3cdcb0
feat(erasure): Firestore rules — erasure_requests create/read, erasur…
Exc1D 77b53be
feat(erasure): Storage rules — citizen read-own report media, deny un…
Exc1D da7dd19
feat(erasure): export requestDataErasure, setErasureLegalHold, erasur…
Exc1D a150d1a
feat(erasure): citizen PWA erasure service — callable wrapper + signO…
Exc1D 14e86fd
feat(erasure): DeleteAccountFlow — two-step modal with typing gate
Exc1D 7f5295f
feat(erasure): GoodbyeScreen and profile route wired to DeleteAccount…
Exc1D c4f2c2c
docs: Phase 8C complete — progress, learnings, and open SMS erasure gap
Exc1D 3a214d5
fixup! fix(erasure): approve deny path — Auth re-enable, sentinel del…
Exc1D 87d2bdc
fixup! feat(erasure): citizen PWA erasure service — callable wrapper …
Exc1D b986bfa
fixup! feat(erasure): Firestore rules — erasure_requests create/read,…
Exc1D 3db4a09
fix: address CI rule coverage + Sourcery review comments
Exc1D 2eee2ac
fix(coderabbit): address remaining PR #82 review comments
Exc1D File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
|
|
||
| const { mockCallable, mockSignOut } = vi.hoisted(() => ({ | ||
| mockCallable: vi.fn(), | ||
| mockSignOut: vi.fn(), | ||
| })) | ||
| vi.mock('../services/firebase.js', () => ({ | ||
| fns: vi.fn(), | ||
| auth: vi.fn(), | ||
| httpsCallable: () => mockCallable, | ||
| })) | ||
| vi.mock('firebase/auth', () => ({ signOut: mockSignOut })) | ||
|
|
||
| import { requestDataErasureAndSignOut } from '../services/erasure.js' | ||
|
|
||
| beforeEach(() => { | ||
| mockCallable.mockClear() | ||
| mockSignOut.mockClear() | ||
| mockCallable.mockResolvedValue({ data: {} }) | ||
| mockSignOut.mockResolvedValue(undefined) | ||
| }) | ||
|
|
||
| describe('requestDataErasureAndSignOut', () => { | ||
| it('calls requestDataErasure callable then signOut', async () => { | ||
| await requestDataErasureAndSignOut() | ||
| expect(mockCallable).toHaveBeenCalledWith({}) | ||
| expect(mockSignOut).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('throws if callable fails without calling signOut', async () => { | ||
| mockCallable.mockRejectedValueOnce({ code: 'internal' }) | ||
| await expect(requestDataErasureAndSignOut()).rejects.toMatchObject({ code: 'internal' }) | ||
| expect(mockSignOut).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('still calls signOut if already-exists error (prior request pending)', async () => { | ||
| mockCallable.mockRejectedValueOnce({ code: 'already-exists' }) | ||
| // already-exists means a prior request is in-flight; treat as success | ||
| // so the user is signed out and the UI transitions to goodbye | ||
| await requestDataErasureAndSignOut() | ||
| expect(mockSignOut).toHaveBeenCalled() | ||
| }) | ||
| }) |
89 changes: 89 additions & 0 deletions
89
apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { render, screen, waitFor } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { DeleteAccountFlow } from './DeleteAccountFlow.js' | ||
|
|
||
| const { mockErasure } = vi.hoisted(() => ({ mockErasure: vi.fn() })) | ||
| vi.mock('../services/erasure.js', () => ({ | ||
| requestDataErasureAndSignOut: (): Promise<void> => mockErasure() as Promise<void>, | ||
| })) | ||
|
|
||
| beforeEach(() => { | ||
| mockErasure.mockReset() | ||
| }) | ||
|
|
||
| describe('DeleteAccountFlow', () => { | ||
| it('renders trigger button', () => { | ||
| render(<DeleteAccountFlow onGoodbye={vi.fn()} />) | ||
| expect(screen.getByRole('button', { name: /delete my account/i })).toBeDefined() | ||
| }) | ||
|
|
||
| it('shows step-1 warning modal on trigger click', async () => { | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={vi.fn()} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| expect(screen.getByText(/delete your account/i)).toBeDefined() | ||
| expect(screen.getByRole('button', { name: /yes, delete my account/i })).toBeDefined() | ||
| }) | ||
|
|
||
| it('shows step-2 typing gate after step-1 confirmation', async () => { | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={vi.fn()} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) | ||
| expect(screen.getByPlaceholderText(/type delete/i)).toBeDefined() | ||
| }) | ||
|
|
||
| it('submit is disabled until user types DELETE', async () => { | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={vi.fn()} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) | ||
| const confirmBtn = screen.getByRole('button', { name: /confirm deletion/i }) | ||
| expect(confirmBtn.hasAttribute('disabled')).toBe(true) | ||
| await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') | ||
| expect(confirmBtn.hasAttribute('disabled')).toBe(false) | ||
| }) | ||
|
|
||
| it('calls erasure service and onGoodbye on successful submission', async () => { | ||
| mockErasure.mockResolvedValueOnce(undefined) | ||
| const onGoodbye = vi.fn() | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={onGoodbye} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) | ||
| await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') | ||
| await user.click(screen.getByRole('button', { name: /confirm deletion/i })) | ||
| await waitFor(() => { | ||
| expect(onGoodbye).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
|
|
||
| it('shows error message on callable failure', async () => { | ||
| mockErasure.mockRejectedValueOnce({ code: 'internal' }) | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={vi.fn()} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) | ||
| await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') | ||
| await user.click(screen.getByRole('button', { name: /confirm deletion/i })) | ||
| await waitFor(() => { | ||
| expect(screen.getByRole('alert')).toBeDefined() | ||
| }) | ||
| }) | ||
|
|
||
| it('calls onGoodbye when callable returns already-exists', async () => { | ||
| // Service swallows already-exists and resolves so user is signed out | ||
| mockErasure.mockResolvedValueOnce(undefined) | ||
| const onGoodbye = vi.fn() | ||
| const user = userEvent.setup() | ||
| render(<DeleteAccountFlow onGoodbye={onGoodbye} />) | ||
| await user.click(screen.getByRole('button', { name: /delete my account/i })) | ||
| await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) | ||
| await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') | ||
| await user.click(screen.getByRole('button', { name: /confirm deletion/i })) | ||
| await waitFor(() => { | ||
| expect(onGoodbye).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { useState, useCallback } from 'react' | ||
| import { requestDataErasureAndSignOut } from '../services/erasure.js' | ||
|
|
||
| interface Props { | ||
| onGoodbye: () => void | ||
| } | ||
|
|
||
| type Step = 'idle' | 'warn' | 'confirm' | 'submitting' | ||
|
|
||
| export function DeleteAccountFlow({ onGoodbye }: Props) { | ||
| const [step, setStep] = useState<Step>('idle') | ||
| const [typed, setTyped] = useState('') | ||
| const [error, setError] = useState<string | null>(null) | ||
|
|
||
| const handleConfirm = useCallback(() => { | ||
| void (async () => { | ||
| setStep('submitting') | ||
| setError(null) | ||
| try { | ||
| await requestDataErasureAndSignOut() | ||
| onGoodbye() | ||
| } catch { | ||
| setError('Something went wrong. Your account has not been deleted. Please try again.') | ||
| setStep('confirm') | ||
| } | ||
| })() | ||
| }, [onGoodbye]) | ||
|
|
||
| const goIdle = useCallback(() => { | ||
| setStep('idle') | ||
| setTyped('') | ||
| setError(null) | ||
| }, []) | ||
|
|
||
| if (step === 'idle') { | ||
| return ( | ||
| <button | ||
| onClick={() => { | ||
| setStep('warn') | ||
| setTyped('') | ||
| setError(null) | ||
| }} | ||
| style={{ color: 'red' }} | ||
| > | ||
| Delete my account | ||
| </button> | ||
| ) | ||
| } | ||
|
|
||
| if (step === 'warn') { | ||
| return ( | ||
| <div role="dialog" aria-modal="true" aria-labelledby="delete-warn-title"> | ||
| <h2 id="delete-warn-title">Delete your account?</h2> | ||
| <p>This will permanently:</p> | ||
| <ul> | ||
| <li>Remove your name, contact info, and account</li> | ||
| <li>Anonymize your reports (they remain as public record)</li> | ||
| <li>Sign you out immediately</li> | ||
| </ul> | ||
| <p>This cannot be undone. Your request will be reviewed before deletion is complete.</p> | ||
| <button onClick={goIdle}>Cancel</button> | ||
| <button | ||
| onClick={() => { | ||
| setStep('confirm') | ||
| setTyped('') | ||
| setError(null) | ||
| }} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| > | ||
| Yes, delete my account → | ||
| </button> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <div role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title"> | ||
| <h2 id="delete-confirm-title">Are you sure?</h2> | ||
| <label htmlFor="delete-confirm">Type DELETE to confirm</label> | ||
| <input | ||
| id="delete-confirm" | ||
| placeholder="Type DELETE" | ||
| value={typed} | ||
| onChange={(e) => { | ||
| setTyped(e.target.value) | ||
| }} | ||
| autoComplete="off" | ||
| /> | ||
| {error && <p role="alert">{error}</p>} | ||
| <button onClick={goIdle}>Cancel</button> | ||
| <button disabled={typed !== 'DELETE' || step === 'submitting'} onClick={handleConfirm}> | ||
| Confirm deletion | ||
| </button> | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| export function GoodbyeScreen() { | ||
| return ( | ||
| <main style={{ display: 'grid', placeItems: 'center', minHeight: '100dvh', padding: '2rem' }}> | ||
| <div style={{ textAlign: 'center', maxWidth: '360px' }}> | ||
| <h1>Request submitted</h1> | ||
| <p> | ||
| Your deletion request has been submitted. You have been signed out. You will not receive | ||
| further notifications. | ||
| </p> | ||
| </div> | ||
| </main> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { signOut } from 'firebase/auth' | ||
| import { auth, fns, httpsCallable } from './firebase.js' | ||
|
|
||
| export async function requestDataErasureAndSignOut(): Promise<void> { | ||
| try { | ||
| await httpsCallable(fns(), 'requestDataErasure')({}) | ||
| } catch (err: unknown) { | ||
| const code = (err as { code?: string }).code | ||
| if (code !== 'already-exists') throw err | ||
| } | ||
| await signOut(auth()) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle idempotent erasure conflicts separately from generic failures.
All failures are collapsed to one generic error, so an
already-existserasure request is treated as hard failure instead of a recoverable/expected state.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents