import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; import { signAction, ACTION_TTL_MS } from '../../src/intake/actionLinks'; import { notifyDraftReady } from '../../src/intake/notifyDraft'; const SECRET = 'integration-intake-secret'; const original = process.env.INTAKE_ACTION_SECRET; beforeAll(() => { process.env.INTAKE_ACTION_SECRET = SECRET; }); afterAll(async () => { if (original === undefined) delete process.env.INTAKE_ACTION_SECRET; else process.env.INTAKE_ACTION_SECRET = original; await pool.end(); await closeDb(); }); beforeEach(async () => { await resetDb(); }); async function seedDraft(): Promise { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name, status) VALUES ('Submission', 'pending') RETURNING id` ); const itemId = rows[0]!.id; await pool.query( `INSERT INTO item_drafts (item_id, state, ai_name) VALUES ($1, 'ready', 'Blue vase')`, [itemId] ); return itemId; } const soon = (): number => Date.now() + ACTION_TTL_MS; function link(itemId: number, action: 'regenerate' | 'discard', expiresAt: number): string { const sig = signAction(itemId, action, expiresAt); return `/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`; } const stateOf = async (itemId: number): Promise => (await pool.query<{ state: string }>(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId])) .rows[0]?.state; describe('the signed action links', () => { /** * The reason GET does not act. Mail scanners and link-rewriting gateways * issue a GET against every URL in a message before a human sees it, so a GET * that discarded a draft would fire itself on delivery — with a valid * signature, looking entirely legitimate in the log, and nobody would know to * go and recover it. */ it('GET confirms without changing anything', async () => { const itemId = await seedDraft(); const res = await request(app).get(link(itemId, 'discard', soon())); expect(res.status).toBe(200); expect(res.body.action).toBe('discard'); expect(await stateOf(itemId)).toBe('ready'); }); it('POST discards and unpublishes the item', async () => { const itemId = await seedDraft(); const res = await request(app).post(link(itemId, 'discard', soon())); expect(res.status).toBe(200); expect(await stateOf(itemId)).toBe('discarded'); const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.status).toBe('pending'); }); // Nothing is deleted, which is what makes a link in an inbox acceptable. it('POST discard keeps the item and its row', async () => { const itemId = await seedDraft(); await request(app).post(link(itemId, 'discard', soon())); const item = await pool.query(`SELECT id FROM items WHERE id = $1`, [itemId]); expect(item.rows).toHaveLength(1); }); it('POST regenerates and clears the attempts', async () => { const itemId = await seedDraft(); await pool.query(`UPDATE item_drafts SET attempts = 3 WHERE item_id = $1`, [itemId]); await request(app).post(link(itemId, 'regenerate', soon())); const { rows } = await pool.query( `SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0 }); }); it('refuses a forged signature', async () => { const itemId = await seedDraft(); const res = await request(app).post( `/api/intake-actions/${itemId}/discard?expires=${soon()}&sig=forged` ); expect(res.status).toBe(403); expect(await stateOf(itemId)).toBe('ready'); }); // The signature names the item, so one link must not act on another. it('refuses a signature minted for a different item', async () => { const mine = await seedDraft(); const other = await seedDraft(); const expires = soon(); const sig = signAction(other, 'discard', expires); const res = await request(app).post( `/api/intake-actions/${mine}/discard?expires=${expires}&sig=${sig}` ); expect(res.status).toBe(403); expect(await stateOf(mine)).toBe('ready'); }); // And it names the action, so a regenerate link cannot be upgraded. it('refuses a signature minted for a different action', async () => { const itemId = await seedDraft(); const expires = soon(); const sig = signAction(itemId, 'regenerate', expires); const res = await request(app).post( `/api/intake-actions/${itemId}/discard?expires=${expires}&sig=${sig}` ); expect(res.status).toBe(403); expect(await stateOf(itemId)).toBe('ready'); }); it('refuses an expired link', async () => { const itemId = await seedDraft(); const res = await request(app).post(link(itemId, 'discard', Date.now() - 1000)); expect(res.status).toBe(403); expect(await stateOf(itemId)).toBe('ready'); }); // There is no signable publish, and asking for one must not find a handler. it('refuses an action it does not recognise', async () => { const itemId = await seedDraft(); const res = await request(app).post( `/api/intake-actions/${itemId}/publish?expires=${soon()}&sig=anything` ); expect(res.status).toBe(404); }); it('404s for an item with no draft', async () => { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name) VALUES ('ordinary') RETURNING id` ); const res = await request(app).post(link(rows[0]!.id, 'discard', soon())); expect(res.status).toBe(404); }); }); describe('the notification itself', () => { // Nowhere to send it is a working configuration, and must not throw into the // worker and fail a draft that was written correctly. it('does nothing when no recipient is configured', async () => { const itemId = await seedDraft(); await expect(notifyDraftReady(itemId)).resolves.toBeUndefined(); }); it('does not throw when the item has no draft', async () => { const { rows } = await pool.query<{ id: number }>( `INSERT INTO items (name) VALUES ('ordinary') RETURNING id` ); await expect(notifyDraftReady(rows[0]!.id)).resolves.toBeUndefined(); }); });