diff --git a/packages/app/src/routes/__tests__/identity-routes.test.ts b/packages/app/src/routes/__tests__/identity-routes.test.ts index 30c05cf..80c008a 100644 --- a/packages/app/src/routes/__tests__/identity-routes.test.ts +++ b/packages/app/src/routes/__tests__/identity-routes.test.ts @@ -7,7 +7,12 @@ import * as s3 from '../../services/s3-storage.js'; import * as faceMatch from '../../identity/face-match-client.js'; import { makeToken, setTestJwtSecret } from './test-helpers.js'; -beforeAll(() => setTestJwtSecret()); +beforeAll(() => { + setTestJwtSecret(); + // The routes gate on this env var before touching storage, so it has to be + // present for the normal paths even though S3StorageService is mocked. + process.env.DOCUMENTS_S3_BUCKET = 'test-bucket'; +}); afterEach(() => vi.restoreAllMocks()); const agencyId = '00000000-0000-4000-8000-00000000c001'; @@ -290,6 +295,47 @@ describe('request body limits', () => { }); }); +describe('storage not configured', () => { + afterEach(() => { process.env.DOCUMENTS_S3_BUCKET = 'test-bucket'; }); + + it('refuses enrolment with a reason instead of a bare 500', async () => { + // Constructing the storage client without a bucket throws, which would + // otherwise surface as a 500 at the exact moment somebody photographs + // their own face. + delete process.env.DOCUMENTS_S3_BUCKET; + mockRepo(); + + const res = await request(createApp()) + .post('/identity/enroll') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('STORAGE_NOT_CONFIGURED'); + }); + + it('refuses verification the same way', async () => { + delete process.env.DOCUMENTS_S3_BUCKET; + mockRepo(); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + + expect(res.status).toBe(503); + }); + + it('reports storage separately from the matching provider', async () => { + delete process.env.DOCUMENTS_S3_BUCKET; + mockRepo(); + + const res = await request(createApp()).get('/identity/status').set('Authorization', auth()); + + expect(res.body.storageConfigured).toBe(false); + }); +}); + describe('GET /identity/status', () => { it('states plainly that liveness is not supported', async () => { mockRepo(); diff --git a/packages/app/src/routes/identity-routes.ts b/packages/app/src/routes/identity-routes.ts index 5a7c8b4..3f49970 100644 --- a/packages/app/src/routes/identity-routes.ts +++ b/packages/app/src/routes/identity-routes.ts @@ -75,6 +75,18 @@ function decodeImage(base64: string): Buffer | null { } } +/** + * Whether photo storage is wired up. + * + * Separate from the matching provider: an agency can have Rekognition selected + * and still have no bucket, or the reverse. Both have to be true before a + * caregiver should be invited to point a camera at their face, otherwise the + * first thing they meet is a 500 at the moment they take the photo. + */ +function isPhotoStorageConfigured(): boolean { + return Boolean(process.env.DOCUMENTS_S3_BUCKET?.trim()); +} + /** Object keys are namespaced per agency so a retention sweep can scope by prefix. */ function referenceKey(agencyId: string, caregiverId: string): string { return `identity/${agencyId}/${caregiverId}/reference.jpg`; @@ -96,7 +108,10 @@ router.get('/status', requireCapability('evv.read'), async (req: Request, res: R repo.findEnrollment(req.auth.caregiverId, req.auth.agencyId), ]); res.json({ + // Matching provider and photo storage are reported separately so the app + // can say which half is missing instead of a vague "not available". configured: isIdentityVerificationConfigured(), + storageConfigured: isPhotoStorageConfigured(), consented: consent !== null, consentVersion: consent?.consentVersion ?? null, enrolled: enrollment !== null, @@ -188,6 +203,15 @@ router.post('/enroll', requireCapability('evv.write'), async (req: Request, res: if (!image) { return res.status(400).json({ message: 'That photo could not be read. Please retake it.' }); } + // Fail before the photo goes anywhere, with a reason. Constructing the + // storage client without a bucket throws, which would otherwise surface as + // a bare 500 at the exact moment somebody photographs their own face. + if (!isPhotoStorageConfigured()) { + return res.status(503).json({ + message: 'Identity photo storage is not set up for this agency yet.', + code: 'STORAGE_NOT_CONFIGURED', + }); + } try { const db = req.app.get('db') as Knex; @@ -236,6 +260,12 @@ router.post('/verify', requireCapability('evv.write'), async (req: Request, res: if (!image) { return res.status(400).json({ message: 'That photo could not be read. Please retake it.' }); } + if (!isPhotoStorageConfigured()) { + return res.status(503).json({ + message: 'Identity photo storage is not set up for this agency yet.', + code: 'STORAGE_NOT_CONFIGURED', + }); + } const db = req.app.get('db') as Knex; const repo = new IdentityRepository(db); diff --git a/packages/mobile/src/features/identity/IdentityScreen.tsx b/packages/mobile/src/features/identity/IdentityScreen.tsx index 6ab7f27..e3c520d 100644 --- a/packages/mobile/src/features/identity/IdentityScreen.tsx +++ b/packages/mobile/src/features/identity/IdentityScreen.tsx @@ -280,6 +280,17 @@ export default function IdentityScreen() { ) : null} + {step === 'unavailable' ? ( + + Not available yet + + Your agency has not finished setting up identity checks, so there is nowhere to + keep your photo yet. Nothing is needed from you. This screen will let you set + yours up once they are done. + + + ) : null} + {step === 'consent' ? ( Before we start diff --git a/packages/mobile/src/lib/identity.test.ts b/packages/mobile/src/lib/identity.test.ts index 95dd2af..e0fc6fc 100644 --- a/packages/mobile/src/lib/identity.test.ts +++ b/packages/mobile/src/lib/identity.test.ts @@ -114,3 +114,29 @@ describe('isUploadableCapture', () => { expect(MAX_UPLOAD_BASE64_CHARS).toBeLessThan(3 * 1024 * 1024); }); }); + +describe('storage gate', () => { + it('hides the camera when the server has nowhere to keep the photo', () => { + // Inviting somebody to photograph their face and then failing to store it + // is the worst order to discover missing configuration in. + expect(stepFor(status({ storageConfigured: false }))).toBe('unavailable'); + expect(stepFor(status({ storageConfigured: false, consented: true }))).toBe('unavailable'); + }); + + it('still shows an already-enrolled caregiver their setup', () => { + // Their photo exists; a transient config gap should not imply it is gone. + expect( + stepFor(status({ storageConfigured: false, consented: true, enrolled: true })), + ).toBe('ready'); + }); + + it('proceeds normally once storage is configured', () => { + expect(stepFor(status({ storageConfigured: true }))).toBe('consent'); + expect(stepFor(status({ storageConfigured: true, consented: true }))).toBe('enroll'); + }); + + it('keeps working against an API that predates the field', () => { + // Absent is not the same as false; an older server should behave as before. + expect(stepFor(status())).toBe('consent'); + }); +}); diff --git a/packages/mobile/src/lib/identity.ts b/packages/mobile/src/lib/identity.ts index c79a72d..c113b98 100644 --- a/packages/mobile/src/lib/identity.ts +++ b/packages/mobile/src/lib/identity.ts @@ -15,6 +15,12 @@ export type IdentityOutcome = export interface IdentityStatus { /** Whether a real matching provider is wired up server-side. */ configured: boolean; + /** + * Whether photo storage is wired up. Separate from `configured`: without a + * bucket there is nowhere to put a photo, so the camera must not be offered + * at all, whatever the matching provider is doing. + */ + storageConfigured?: boolean; consented: boolean; enrolled: boolean; enrolledAt: string | null; @@ -25,10 +31,15 @@ export interface IdentityStatus { } /** Which panel the screen should show, derived from server state alone. */ -export type IdentityStep = 'loading' | 'consent' | 'enroll' | 'ready'; +export type IdentityStep = 'loading' | 'unavailable' | 'consent' | 'enroll' | 'ready'; export function stepFor(status: IdentityStatus | null): IdentityStep { if (!status) return 'loading'; + // No storage means no camera. Inviting somebody to photograph their own face + // and then failing to keep it is the worst order to discover this in. + // Treated as absent only when the server explicitly says so, so an older API + // that omits the field keeps its previous behaviour. + if (status.storageConfigured === false && !status.enrolled) return 'unavailable'; // Consent first, always. Nothing biometric is captured before it, and the // server enforces the same order, so a client that skipped ahead would only // earn a 403.