|
|
@@ -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');
|
|
|
|
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|