- Privacy page: news opt-in per channel with history, download my data (JSON + PDF), correction and deletion requests - Bulk messages are an Event update or News & announcements; news only reaches people who opted in, with unsubscribe links, one-click List-Unsubscribe headers and "Reply STOP" on WhatsApp - WhatsApp STOP via WAWP's message webhook, checked against a secret in the webhook URL - Sign-up opt-in box; existing accounts kept opted in and asked once - Admin -> Data requests with 30-day due dates and a daily reminder job - Deleting an account is a request; anonymising now covers attendees, form answers, consent details, payer details in provider payloads, etc. - Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
162 lines
8.5 KiB
JavaScript
162 lines
8.5 KiB
JavaScript
jest.mock('../src/config/db', () => ({
|
|
user: { findUnique: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
|
|
communicationConsent: { createMany: jest.fn(), create: jest.fn() },
|
|
messagingOptOut: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
|
|
registration: { findMany: jest.fn() },
|
|
}));
|
|
|
|
process.env.JWT_SECRET = 'test-secret';
|
|
process.env.FRONTEND_URL = 'https://events.example.org';
|
|
process.env.BACKEND_URL = 'https://api.example.org';
|
|
|
|
const prisma = require('../src/config/db');
|
|
const privacy = require('../src/utils/privacy');
|
|
const { phoneFromChatId } = require('../src/utils/whatsapp');
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
prisma.messagingOptOut.findMany.mockResolvedValue([]);
|
|
});
|
|
|
|
describe('unsubscribe tokens', () => {
|
|
test('round-trip an account and an address', () => {
|
|
expect(privacy.readUnsubscribeToken(privacy.unsubscribeToken({ userId: 'u1' }))).toEqual({ userId: 'u1' });
|
|
const t = privacy.unsubscribeToken({ address: 'a@b.co', channel: 'email' });
|
|
expect(privacy.readUnsubscribeToken(t)).toEqual({ address: 'a@b.co', channel: 'email' });
|
|
});
|
|
|
|
test('a tampered token is refused', () => {
|
|
const [, sig] = privacy.unsubscribeToken({ userId: 'u1' }).split('.');
|
|
const forged = `${Buffer.from(JSON.stringify({ u: 'someone-else' })).toString('base64url')}.${sig}`;
|
|
expect(privacy.readUnsubscribeToken(forged)).toBeNull();
|
|
expect(privacy.readUnsubscribeToken('garbage')).toBeNull();
|
|
expect(privacy.readUnsubscribeToken('')).toBeNull();
|
|
});
|
|
|
|
test('an address token needs a known channel', () => {
|
|
const payload = Buffer.from(JSON.stringify({ a: 'x@y.z', c: 'sms' })).toString('base64url');
|
|
const real = privacy.unsubscribeToken({ address: 'x@y.z', channel: 'email' });
|
|
expect(privacy.readUnsubscribeToken(`${payload}.${real.split('.')[1]}`)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('filterRecipients', () => {
|
|
const users = [
|
|
{ id: 'in', email: 'in@x.co', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true },
|
|
{ id: 'out', email: 'Out@x.co', phoneNumber: '27820000002', newsEmail: false, newsWhatsApp: false },
|
|
];
|
|
|
|
test('updates go to everyone, with the account filled in', async () => {
|
|
prisma.user.findMany.mockResolvedValue(users);
|
|
const res = await privacy.filterRecipients([{ email: 'in@x.co' }, { email: 'out@x.co' }, { email: 'guest@x.co' }], { channel: 'email', kind: 'update' });
|
|
expect(res.skipped).toBe(0);
|
|
expect(res.recipients.map(r => r.userId)).toEqual(['in', 'out', null]);
|
|
expect(prisma.messagingOptOut.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('news skips accounts without the opt-in (any letter case) and unsubscribed addresses', async () => {
|
|
prisma.user.findMany.mockResolvedValue(users);
|
|
prisma.messagingOptOut.findMany.mockResolvedValue([{ address: 'gone@x.co' }]);
|
|
const res = await privacy.filterRecipients(
|
|
[{ email: 'in@x.co' }, { email: 'OUT@x.co' }, { email: 'gone@x.co' }, { email: 'new@x.co' }],
|
|
{ channel: 'email', kind: 'news' },
|
|
);
|
|
expect(res.recipients.map(r => r.email)).toEqual(['in@x.co', 'new@x.co']);
|
|
expect(res.skipped).toBe(2);
|
|
});
|
|
|
|
test('WhatsApp news uses the WhatsApp opt-in and normalised numbers', async () => {
|
|
prisma.user.findMany.mockResolvedValue(users);
|
|
const res = await privacy.filterRecipients([{ phone: '082 000 0001' }, { phone: '0820000002' }], { channel: 'whatsapp', kind: 'news' });
|
|
expect(res.recipients).toHaveLength(1);
|
|
expect(res.recipients[0].userId).toBe('in');
|
|
});
|
|
});
|
|
|
|
describe('setNewsPreferences', () => {
|
|
test('logs only the channels that changed, and WhatsApp needs a phone', async () => {
|
|
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: null, newsEmail: true, newsWhatsApp: false });
|
|
prisma.user.update.mockResolvedValue({ newsEmail: false, newsWhatsApp: false, newsAskedAt: null });
|
|
await privacy.setNewsPreferences('u1', { email: false, whatsapp: true }, { source: 'privacy_centre' });
|
|
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ data: { newsEmail: false } }));
|
|
expect(prisma.communicationConsent.createMany).toHaveBeenCalledWith({
|
|
data: [{ userId: 'u1', channel: 'email', granted: false, source: 'privacy_centre', ip: null }],
|
|
});
|
|
});
|
|
|
|
test('the first answer is logged even when nothing changes', async () => {
|
|
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true });
|
|
prisma.user.update.mockResolvedValue({ newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() });
|
|
await privacy.setNewsPreferences('u1', { email: true, whatsapp: true }, { source: 'prompt', answered: true, logAll: true });
|
|
expect(prisma.user.update.mock.calls[0][0].data.newsAskedAt).toBeInstanceOf(Date);
|
|
expect(prisma.communicationConsent.createMany.mock.calls[0][0].data).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('footers', () => {
|
|
test('news emails carry an unsubscribe link and one-click headers', () => {
|
|
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
|
|
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
|
|
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
|
|
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
|
|
});
|
|
|
|
test('update emails explain why and link to the preferences, without unsubscribe headers', () => {
|
|
const f = privacy.bulkEmailFooter({ kind: 'update', recipient: { email: 'a@b.co' }, eventTitle: 'Camp <2026>' });
|
|
expect(f.html).toContain('you registered for Camp <2026>');
|
|
expect(f.html).toContain('/dashboard/user/privacy');
|
|
expect(f.headers).toEqual({});
|
|
});
|
|
|
|
test('news WhatsApps mention STOP', () => {
|
|
expect(privacy.whatsAppNewsFooter({ phone: '0820000001' })).toMatch(/Reply STOP/);
|
|
});
|
|
});
|
|
|
|
describe('WhatsApp STOP', () => {
|
|
test.each(['STOP', 'stop', ' Stop. ', 'unsubscribe', 'Opt out'])('%p is an opt-out', text => {
|
|
expect(privacy.isStopKeyword(text)).toBe(true);
|
|
});
|
|
test.each(['please stop by the office', 'STOPPED', '', null])('%p is not', text => {
|
|
expect(privacy.isStopKeyword(text)).toBe(false);
|
|
});
|
|
|
|
test('phone numbers only from personal chats', () => {
|
|
expect(phoneFromChatId('27821234567@c.us')).toBe('27821234567');
|
|
expect(phoneFromChatId('1203630@g.us')).toBeNull();
|
|
expect(phoneFromChatId('99887766@lid')).toBeNull();
|
|
});
|
|
|
|
test('an account number turns off WhatsApp news; an unknown number is opted out', async () => {
|
|
prisma.user.findFirst.mockResolvedValueOnce({ id: 'u1' });
|
|
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27821234567', newsEmail: true, newsWhatsApp: true });
|
|
prisma.user.update.mockResolvedValue({});
|
|
expect(await privacy.whatsAppStop('27821234567')).toBe('account');
|
|
expect(prisma.user.update.mock.calls[0][0].data).toEqual({ newsWhatsApp: false });
|
|
|
|
prisma.user.findFirst.mockResolvedValueOnce(null);
|
|
prisma.messagingOptOut.findUnique.mockResolvedValueOnce(null);
|
|
expect(await privacy.whatsAppStop('0829999999')).toBe('address');
|
|
expect(prisma.messagingOptOut.create).toHaveBeenCalledWith({ data: { channel: 'whatsapp', address: '27829999999', source: 'whatsapp_stop' } });
|
|
});
|
|
});
|
|
|
|
describe('anonymising helpers', () => {
|
|
test('scrubPersonal removes payer details from provider payloads, keeps amounts and ids', () => {
|
|
const raw = { m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00', name_first: 'Ann', name_last: 'Lee', email_address: 'ann@x.co', nested: { customer: 'Ann Lee', id: 'x' } };
|
|
expect(privacy.scrubPersonal(raw)).toEqual({
|
|
m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00',
|
|
name_first: '[deleted]', name_last: '[deleted]', email_address: '[deleted]', nested: { customer: '[deleted]', id: 'x' },
|
|
});
|
|
});
|
|
|
|
test('deletion is blocked by registrations for events that have not ended', async () => {
|
|
prisma.registration.findMany.mockResolvedValue([{ id: 'r1', status: 'paid', event: { id: 'e1', title: 'Camp', endDate: new Date('2099-01-01') } }]);
|
|
const blockers = await privacy.deletionBlockers('u1');
|
|
expect(blockers).toEqual([{ registrationId: 'r1', status: 'paid', eventId: 'e1', eventTitle: 'Camp', endDate: new Date('2099-01-01') }]);
|
|
const where = prisma.registration.findMany.mock.calls[0][0].where;
|
|
expect(where.status).toEqual({ not: 'cancelled' });
|
|
expect(where.event.endDate.gte).toBeInstanceOf(Date);
|
|
});
|
|
});
|