Feature/260 email the upload link #292

Merged
bermudalamb merged 13 commits from feature/260-email-the-upload-link into main 2026-09-03 18:50:37 -05:00
3 changed files with 93 additions and 30 deletions
Showing only changes of commit 5a9022d8d1 - Show all commits
@@ -4,6 +4,14 @@ import app from '../../src/app';
import { pool } from '../../src/db'; import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb'; import { resetDb, closeDb } from './setup/testDb';
// Every submission-cap test here issues a link, which now emails it. Mocked
// 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.
jest.mock('../../src/mailer', () => ({
sendMail: jest.fn().mockResolvedValue('sent')
}));
const UPLOADS_DIR = process.env.UPLOADS_DIR as string; const UPLOADS_DIR = process.env.UPLOADS_DIR as string;
// The same 1x1 PNG the upload validation suite uses, so the accepted case // The same 1x1 PNG the upload validation suite uses, so the accepted case
@@ -4,6 +4,14 @@ import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb'; import { resetDb, closeDb } from './setup/testDb';
import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert'; import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert';
// makeLink() issues a link on every test, which now emails it. Mocked 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.
jest.mock('../../src/mailer', () => ({
sendMail: jest.fn().mockResolvedValue('sent')
}));
const PNG = Buffer.from( const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64' 'base64'
@@ -3,8 +3,33 @@ import app from '../../src/app';
import { pool } from '../../src/db'; import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb'; 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 () => { beforeEach(async () => {
await resetDb(); await resetDb();
sentMail.mockReset();
sentMail.mockResolvedValue('sent');
}); });
afterAll(async () => { afterAll(async () => {
@@ -112,12 +137,6 @@ describe('revoking an upload link', () => {
}); });
describe('emailing the link to its recipient', () => { 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 () => { it('refuses to create a link with no address', async () => {
const res = await request(app) const res = await request(app)
.post('/api/admin/upload-links') .post('/api/admin/upload-links')
@@ -151,37 +170,29 @@ describe('emailing the link to its recipient', () => {
}); });
// The point of the whole change: QA blocks delivery by design, so a link that // 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. // was not actually emailed must not be reported as though it was. Driven
it('says the mail was not sent when SMTP is not configured', async () => { // through the mock's return value rather than the SMTP_USER /
delete process.env.SMTP_USER; // MAIL_ALLOWLIST env vars sendMail itself would branch on — see the file
delete process.env.SMTP_PASSWORD; // 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) const res = await request(app)
.post('/api/admin/upload-links') .post('/api/admin/upload-links')
.send({ label: 'Sarah', email: 'sarah@example.com' }); .send({ label: 'Sarah', email: 'sarah@example.com' });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.mail).toEqual({ sent: false, outcome: 'skipped-unconfigured' }); expect(res.body.mail).toEqual({ sent: false, outcome });
}); }
);
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 // 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 // the token is shown exactly once and a rollback would hand them a different
// one on the retry. // one on the retry.
it('still returns a usable link when the mail did not go', async () => { it('still returns a usable link when the mail did not go', async () => {
delete process.env.SMTP_USER; sentMail.mockResolvedValueOnce('skipped-unconfigured');
const res = await request(app) const res = await request(app)
.post('/api/admin/upload-links') .post('/api/admin/upload-links')
@@ -205,4 +216,40 @@ describe('emailing the link to its recipient', () => {
expect(older.contact_email).toBeNull(); expect(older.contact_email).toBeNull();
expect(newer.contact_email).toBe('sarah@example.com'); 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');
});
}); });