fc595409b4
Part B of #1264. Flag off by default, so an install that never enables it gains no route, no nav entry and no way to mass-mail. A campaign is a body plus a recipient rule. Queueing one writes ordinary email_queue rows (email_type 'newsletter', origin 'campaign', new campaign_id), so retry, rendered_html, sent_at and error_message all come from the existing processor rather than a parallel sender. Throttling staggers scheduled_at; the processor loop is untouched. Two rules the service enforces: no raw HTML is ever stored (sanitized on write and again on render, idempotently), and opt-out is checked at queue time AND again at send time. Migration 199 adds email_campaigns, email_campaign_recipients, email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and the newsletters.view / newsletters.send permissions. Three rounds of external review are folded in, including several that would otherwise have shipped broken: - Campaign rows never came due on SQLite. queueEmail writes a Date, which the sqlite3 binding stores as epoch ms; ISO text in the same column compares as TEXT against an INTEGER, and SQLite orders every INTEGER below every TEXT. The feature silently sent nothing there. - The flag had no Settings card and no sidebar entry, so it could not be enabled through the UI at all. - Consent is per ADDRESS, not per row: two accounts sharing an inbox meant unsubscribing stopped one and not the other, at both queue and send time. - The unsubscribe GET mutated consent, so a mail-security scanner walking a campaign could have unsubscribed much of the list. GET now confirms, POST acts. - The rate ceiling is clamped to the queue's real throughput (10/min), so the composer's estimate stops being wrong by up to 12x. Closes #1264
180 lines
7.0 KiB
JavaScript
180 lines
7.0 KiB
JavaScript
/**
|
|
* Public newsletter unsubscribe (#1264).
|
|
*
|
|
* The property under test is uniformity: a valid token, a forged one, an
|
|
* unknown customer and an already-unsubscribed customer must be
|
|
* indistinguishable from outside. Anything that varies — status, body,
|
|
* headers, an error page — is an oracle that turns this endpoint into a way
|
|
* to enumerate which customer ids exist.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-unsub-'));
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
|
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'unsub-route-secret';
|
|
|
|
const request = require('supertest');
|
|
const { bootCrmDb, buildRouteApp } = require('../integration/helpers/crmDb');
|
|
|
|
describe('GET /api/public/newsletter/unsubscribe/:token', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
let newsletterService;
|
|
let customerId;
|
|
|
|
const MOUNT = '/api/public/newsletter';
|
|
const get = (token) => request(app).get(`${MOUNT}/unsubscribe/${token}`);
|
|
const post = (token) => request(app).post(`${MOUNT}/unsubscribe/${token}`);
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
newsletterService = require('../../src/services/newsletterService');
|
|
app = buildRouteApp(MOUNT, require('../../src/routes/publicNewsletter'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Cleared too: several cases assert on the presence or ABSENCE of a
|
|
// consent entry, which earlier cases in this file also write.
|
|
await db('activity_logs').del();
|
|
await db('customer_accounts').del();
|
|
const [id] = await db('customer_accounts').insert({
|
|
email: 'sub@example.com', is_active: 1, marketing_opt_out: 0,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
customerId = typeof id === 'object' ? id.id : id;
|
|
});
|
|
|
|
it('opts the customer out and stamps the timestamp', async () => {
|
|
const res = await post(newsletterService.unsubscribeToken(customerId));
|
|
|
|
expect(res.status).toBe(200);
|
|
const row = await db('customer_accounts').where({ id: customerId }).first();
|
|
expect(row.marketing_opt_out).toBeTruthy();
|
|
expect(row.marketing_opt_out_at).toBeTruthy();
|
|
});
|
|
|
|
it('needs no authentication', async () => {
|
|
// No cookie, no header, no session — a mail client on any device.
|
|
expect((await post(newsletterService.unsubscribeToken(customerId))).status).toBe(200);
|
|
});
|
|
|
|
it('is idempotent — clicking twice is not an error', async () => {
|
|
const token = newsletterService.unsubscribeToken(customerId);
|
|
const first = await post(token);
|
|
const second = await post(token);
|
|
|
|
expect(second.status).toBe(first.status);
|
|
expect(second.text).toBe(first.text);
|
|
});
|
|
|
|
it('answers identically for a valid token, a forged one and an unknown id', async () => {
|
|
const valid = await post(newsletterService.unsubscribeToken(customerId));
|
|
const forged = await post('dGFtcGVyZWQtdG9rZW4');
|
|
const unknown = await post(newsletterService.unsubscribeToken(987654));
|
|
|
|
for (const res of [forged, unknown]) {
|
|
expect(res.status).toBe(valid.status);
|
|
expect(res.text).toBe(valid.text);
|
|
expect(res.headers['content-type']).toBe(valid.headers['content-type']);
|
|
}
|
|
});
|
|
|
|
it('leaves other customers untouched when the token is forged', async () => {
|
|
await post('bm90LWEtcmVhbC10b2tlbg');
|
|
const row = await db('customer_accounts').where({ id: customerId }).first();
|
|
expect(row.marketing_opt_out).toBeFalsy();
|
|
});
|
|
|
|
it('rejects an id spliced onto another id\'s signature', async () => {
|
|
const token = newsletterService.unsubscribeToken(customerId);
|
|
const sig = Buffer.from(token, 'base64url').toString('utf8').split('.')[1];
|
|
const forged = Buffer.from(`${customerId + 1}.${sig}`, 'utf8').toString('base64url');
|
|
|
|
await post(forged);
|
|
|
|
// Neither the target nor the spliced neighbour is changed.
|
|
expect((await db('customer_accounts').where({ id: customerId }).first()).marketing_opt_out)
|
|
.toBeFalsy();
|
|
});
|
|
|
|
it('renders a script-free confirmation page', async () => {
|
|
const res = await post(newsletterService.unsubscribeToken(customerId));
|
|
|
|
expect(res.headers['content-type']).toMatch(/text\/html/);
|
|
expect(res.text).toContain('<!DOCTYPE html>');
|
|
expect(res.text).not.toContain('<script');
|
|
// Self-contained: no external asset can make this page fail to render.
|
|
expect(res.text).not.toMatch(/<(?:link|img|iframe)\b/);
|
|
});
|
|
|
|
it('tells the reader transactional mail is unaffected', async () => {
|
|
const res = await post(newsletterService.unsubscribeToken(customerId));
|
|
expect(res.text).toMatch(/transactional/i);
|
|
});
|
|
|
|
it('is not indexable', async () => {
|
|
const res = await post(newsletterService.unsubscribeToken(customerId));
|
|
expect(res.headers['x-robots-tag']).toMatch(/noindex/);
|
|
expect(res.headers['cache-control']).toMatch(/no-store/);
|
|
});
|
|
|
|
it('writes an activity log entry sourced to the link', async () => {
|
|
await post(newsletterService.unsubscribeToken(customerId));
|
|
|
|
const log = await db('activity_logs')
|
|
.where({ activity_type: 'customer_marketing_opt_out' }).orderBy('id', 'desc').first();
|
|
expect(log).toBeTruthy();
|
|
expect(JSON.parse(log.metadata)).toMatchObject({ source: 'link', optOut: true });
|
|
});
|
|
|
|
// A GET must not change consent. Mail-security scanners, link prefetchers
|
|
// and corporate gateways follow every URL in a message before a human sees
|
|
// it — a mutating GET would unsubscribe much of a campaign automatically.
|
|
describe('GET only asks', () => {
|
|
it('does not change consent', async () => {
|
|
const res = await get(newsletterService.unsubscribeToken(customerId));
|
|
|
|
expect(res.status).toBe(200);
|
|
const row = await db('customer_accounts').where({ id: customerId }).first();
|
|
expect(row.marketing_opt_out).toBeFalsy();
|
|
expect(row.marketing_opt_out_at).toBeNull();
|
|
});
|
|
|
|
it('offers a form that posts back to the same token', async () => {
|
|
const token = newsletterService.unsubscribeToken(customerId);
|
|
const res = await get(token);
|
|
|
|
expect(res.text).toMatch(/<form[^>]+method="POST"/i);
|
|
expect(res.text).toContain(`/unsubscribe/${token}`);
|
|
});
|
|
|
|
it('writes no activity entry', async () => {
|
|
await get(newsletterService.unsubscribeToken(customerId));
|
|
const logs = await db('activity_logs')
|
|
.where({ activity_type: 'customer_marketing_opt_out' });
|
|
expect(logs).toHaveLength(0);
|
|
});
|
|
|
|
it('renders the same page for a forged token', async () => {
|
|
const valid = await get(newsletterService.unsubscribeToken(customerId));
|
|
const forged = await get('bm90LWEtdG9rZW4');
|
|
expect(forged.status).toBe(valid.status);
|
|
// Only the form action differs — it echoes the token back.
|
|
expect(forged.text.replace(/action="[^"]*"/, '')).toBe(
|
|
valid.text.replace(/action="[^"]*"/, '')
|
|
);
|
|
});
|
|
});
|
|
});
|