Files
redefined-designs/backend/tests/integration/uploadLinks.integration.test.ts
bermudalambandClaude Opus 5 5a9022d8d1 test(integration): mock the mailer everywhere an upload link is issued (#260)
intake.integration.test.ts, intakeCeiling.integration.test.ts and uploadLinks.integration.test.ts all create upload links, and since #260 that now sends real mail. env.setup.ts never clears SMTP_USER, SMTP_PASSWORD or MAIL_ALLOWLIST, so with those inherited from a developer's shell these three files opened live TLS connections to smtp.gmail.com:465 and, with no allowlist set, actually delivered to sarah@example.com. This is the exact hazard the "Do not add one back" comment in tests/unit/mailOutcome.test.ts already warns about, reintroduced at the integration layer.

All three now mock ../../src/mailer the same way accountDetails.integration.test.ts, favorites.integration.test.ts and resendVerification.integration.test.ts already do. uploadLinks.integration.test.ts is the one place that needs to see specific MailOutcome values come back through the route, so its two outcome tests were restructured to drive the mock's return value directly (sentMail.mockResolvedValueOnce(...)) instead of threading SMTP_USER/MAIL_ALLOWLIST through the real sendMail. That is a cleaner test anyway: it isolates the route's job (reporting whatever outcome sendMail returns) from sendMail's own skip logic, which is already covered hermetically by mailOutcome.test.ts and mailAllowlist.test.ts.

Also addresses the related minor finding that nothing asserted the mail actually carried the working link: required: ['submitUrl'] on the template only guards that the placeholder is present in the body, not that the route supplied a correct value for it. A new test in uploadLinks.integration.test.ts inspects the mock's captured call and asserts the html contains the created token's /submit/ URL, and covers the three submissionsAllowed phrasings (a numeric cap, a cap of exactly one, and uncapped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:30:35 -05:00

256 lines
10 KiB
TypeScript

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<typeof sendMail>;
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<MailOutcome>(['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');
});
});