Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/app/src/routes/__tests__/identity-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 30 additions & 0 deletions packages/app/src/routes/identity-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions packages/mobile/src/features/identity/IdentityScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ export default function IdentityScreen() {
</View>
) : null}

{step === 'unavailable' ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Not available yet</Text>
<Text style={styles.cardHint}>
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.
</Text>
</View>
) : null}

{step === 'consent' ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Before we start</Text>
Expand Down
26 changes: 26 additions & 0 deletions packages/mobile/src/lib/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
13 changes: 12 additions & 1 deletion packages/mobile/src/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
Loading