import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); describe('issuing an upload link', () => { it('returns the token exactly once, at creation', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(created.status).toBe(201); expect(created.body.label).toBe('Sarah'); expect(created.body.token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(created.body.url).toContain(`/submit/${created.body.token}`); const listed = await request(app).get('/api/admin/upload-links'); expect(listed.status).toBe(200); expect(listed.body).toHaveLength(1); // The whole point of storing a digest: the listing cannot hand it back. expect(listed.body[0].token).toBeUndefined(); expect(listed.body[0].token_hash).toBeUndefined(); }); it('stores the digest rather than the token', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Estate sale box 3', email: 'sarah@example.com' }); const { rows } = await pool.query<{ token_hash: string }>( `SELECT token_hash FROM upload_links` ); expect(rows[0]?.token_hash).not.toBe(created.body.token); expect(rows[0]?.token_hash).toMatch(/^[a-f0-9]{64}$/); }); it('refuses a link with no label', async () => { const res = await request(app).post('/api/admin/upload-links').send({ label: ' ' }); expect(res.status).toBe(400); }); it('refuses a non-positive submission cap', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Bad cap', email: 'sarah@example.com', maxSubmissions: 0 }); expect(res.status).toBe(400); }); // Omitting the field is the common case, so it is the case that has to be // safe. An unbounded link should be something asked for, not something that // happens when nobody thought about it. it('bounds a link that was created without a cap', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(res.status).toBe(201); expect(res.body.max_submissions).toBe(25); }); it('allows unlimited when it is asked for explicitly', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Always on', email: 'sarah@example.com', maxSubmissions: null }); expect(res.status).toBe(201); expect(res.body.max_submissions).toBeNull(); }); }); describe('revoking an upload link', () => { it('stamps revoked_at and reports it in the listing', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Temporary', email: 'sarah@example.com' }); const revoked = await request(app) .post(`/api/admin/upload-links/${created.body.id}/revoke`); expect(revoked.status).toBe(200); expect(revoked.body.revoked_at).not.toBeNull(); }); // The useful fact is when access ended, so a second click must not rewrite // it — and it must not be an error either, because a button that fails on a // double-click teaches people to distrust it. it('is idempotent, keeping the original timestamp', async () => { const created = await request(app) .post('/api/admin/upload-links') .send({ label: 'Temporary', email: 'sarah@example.com' }); const first = await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`); const second = await request(app).post(`/api/admin/upload-links/${created.body.id}/revoke`); expect(second.status).toBe(200); expect(second.body.revoked_at).toBe(first.body.revoked_at); }); it('404s for a link that does not exist', async () => { const res = await request(app).post('/api/admin/upload-links/9999/revoke'); expect(res.status).toBe(404); }); }); describe('emailing the link to its recipient', () => { const original = { ...process.env }; afterEach(() => { process.env = { ...original }; }); it('refuses to create a link with no address', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/email/i); }); it('refuses an address that is not one', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'not-an-address' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/email/i); }); it('stores the address on the link', async () => { const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(res.status).toBe(201); const { rows } = await pool.query<{ contact_email: string }>( `SELECT contact_email FROM upload_links WHERE id = $1`, [res.body.id] ); expect(rows[0]?.contact_email).toBe('sarah@example.com'); }); // The point of the whole change: QA blocks delivery by design, so a link that // was not actually emailed must not be reported as though it was. it('says the mail was not sent when SMTP is not configured', async () => { delete process.env.SMTP_USER; delete process.env.SMTP_PASSWORD; const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(res.status).toBe(201); expect(res.body.mail).toEqual({ sent: false, outcome: 'skipped-unconfigured' }); }); it('says the mail was not sent when the address is not allowlisted', async () => { process.env.SMTP_USER = 'user'; process.env.SMTP_PASSWORD = 'password'; process.env.MAIL_ALLOWLIST = 'someone@example.com'; const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(res.status).toBe(201); expect(res.body.mail).toEqual({ sent: false, outcome: 'skipped-blocked' }); }); // A send that could not happen must never cost the admin the link, because // the token is shown exactly once and a rollback would hand them a different // one on the retry. it('still returns a usable link when the mail did not go', async () => { delete process.env.SMTP_USER; const res = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com' }); expect(res.body.token).toBeTruthy(); expect(res.body.url).toContain(res.body.token); expect(res.body.mail.sent).toBe(false); }); it('lists the address, and tolerates a link that has none', async () => { await pool.query(`INSERT INTO upload_links (label, token_hash) VALUES ('Older link', 'digest')`); await request(app) .post('/api/admin/upload-links') .send({ label: 'Newer link', email: 'sarah@example.com' }); const res = await request(app).get('/api/admin/upload-links'); const older = res.body.find((row: { label: string }) => row.label === 'Older link'); const newer = res.body.find((row: { label: string }) => row.label === 'Newer link'); expect(older.contact_email).toBeNull(); expect(newer.contact_email).toBe('sarah@example.com'); }); });