import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; // Every test in this file issues an upload link, which now emails it. // Mocked exactly like the other integration suites that touch mail (see // accountDetails.integration.test.ts, favorites.integration.test.ts, // resendVerification.integration.test.ts) so the suite never opens a real // connection to smtp.gmail.com — see the "Do not add one back" warning in // tests/unit/mailOutcome.test.ts, which this mirrors at the integration // layer. // // Unlike those three, the "emailing the link to its recipient" describe // block below needs to see specific MailOutcome values come back through the // route rather than a single fixed one. Threading real SMTP_USER / // MAIL_ALLOWLIST env vars through the real sendMail was the previous // approach and is exactly the hazard being removed here, so those tests are // restructured to drive the mock's return value directly instead — that also // makes them a cleaner test of the route's outcome-reporting (its job), // separate from sendMail's own skip logic (already covered hermetically by // mailOutcome.test.ts and mailAllowlist.test.ts). jest.mock('../../src/mailer', () => ({ sendMail: jest.fn().mockResolvedValue('sent') })); import { sendMail, MailOutcome } from '../../src/mailer'; const sentMail = sendMail as jest.MockedFunction; beforeEach(async () => { await resetDb(); sentMail.mockReset(); sentMail.mockResolvedValue('sent'); }); 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', () => { 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. Driven // through the mock's return value rather than the SMTP_USER / // MAIL_ALLOWLIST env vars sendMail itself would branch on — see the file // header comment for why. it.each(['skipped-unconfigured', 'skipped-blocked'])( 'reports a %s outcome from sendMail rather than as though it sent', async (outcome) => { sentMail.mockResolvedValueOnce(outcome); 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 }); } ); // 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 () => { sentMail.mockResolvedValueOnce('skipped-unconfigured'); 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'); }); // required: ['submitUrl'] on the template is a guard that the placeholder is // present in the body, not that the route supplied a working value for it. // A route that passed the bare base URL, or dropped the token, would leave // every other test here green — so this asserts the actual captured html, // and covers the submissionsAllowed wording for all three cases (a numeric // cap, a cap of exactly one, and uncapped) at the same time, since all three // are the same kind of claim: what the mail says versus what was created. it('emails a link that actually contains the created token, and states how many items may be sent', async () => { const capped = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com', maxSubmissions: 25 }); expect(capped.status).toBe(201); expect(sentMail).toHaveBeenCalledTimes(1); const [cappedTo, , cappedHtml] = sentMail.mock.calls[0]!; expect(cappedTo).toBe('sarah@example.com'); expect(cappedHtml).toContain(`/submit/${capped.body.token}`); expect(cappedHtml).toContain('25 items'); const single = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com', maxSubmissions: 1 }); expect(single.status).toBe(201); const [, , singleHtml] = sentMail.mock.calls[1]!; expect(singleHtml).toContain(`/submit/${single.body.token}`); expect(singleHtml).toContain('1 item'); expect(singleHtml).not.toContain('1 items'); const uncapped = await request(app) .post('/api/admin/upload-links') .send({ label: 'Sarah', email: 'sarah@example.com', maxSubmissions: null }); expect(uncapped.status).toBe(201); const [, , uncappedHtml] = sentMail.mock.calls[2]!; expect(uncappedHtml).toContain(`/submit/${uncapped.body.token}`); expect(uncappedHtml).toContain('as many items as you like'); }); });