- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
`retention` job: inactive accounts anonymised after 5 years with a
30-day warning (on by default; never staff roles or people with an
upcoming registration), form answers, login history, payer details in
payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
195 lines
10 KiB
JavaScript
195 lines
10 KiB
JavaScript
/**
|
|
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
|
|
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
|
|
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
|
|
* login tokens the tests use.
|
|
*
|
|
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
|
|
* database name. It is always a separate database — never the one you develop against.
|
|
*/
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { execSync } = require('child_process');
|
|
|
|
const root = path.join(__dirname, '..', '..');
|
|
const backend = path.join(root, 'backend');
|
|
const req = (m) => require(require.resolve(m, { paths: [backend] }));
|
|
req('dotenv').config({ path: path.join(backend, '.env') });
|
|
|
|
const { e2eDatabaseUrl } = require('./db-url');
|
|
|
|
async function recreateDatabase(url) {
|
|
const { Client } = req('pg');
|
|
const target = new URL(url);
|
|
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
|
|
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
|
|
const admin = new URL(url);
|
|
admin.pathname = '/postgres';
|
|
admin.search = '';
|
|
const client = new Client({ connectionString: admin.toString() });
|
|
await client.connect();
|
|
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
|
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
await client.end();
|
|
}
|
|
|
|
async function seed(url) {
|
|
process.env.DATABASE_URL = url;
|
|
const prisma = require(path.join(backend, 'src/config/db'));
|
|
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
|
|
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
|
|
|
|
await prisma.appSetting.createMany({
|
|
data: [
|
|
{ key: 'setup_complete', value: 'true' },
|
|
{ key: 'org_name', value: 'E2E Church' },
|
|
{ key: 'org_address', value: '1 Test Street, Pretoria' },
|
|
],
|
|
});
|
|
|
|
const password = await hashPassword('E2e-password-123');
|
|
// Opted in to news and already asked (newsAskedAt), so the one-time news question
|
|
// doesn't cover the dashboards in every test.
|
|
const mk = (role, name, email, phoneNumber) => prisma.user.create({
|
|
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() },
|
|
});
|
|
const users = {
|
|
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
|
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
|
|
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
|
|
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
|
|
};
|
|
|
|
const D = 86400000;
|
|
const now = Date.now();
|
|
const at = (days, hourUtc = 16, minute = 30) => {
|
|
const d = new Date(now + days * D);
|
|
d.setUTCHours(hourUtc, minute, 0, 0);
|
|
return d;
|
|
};
|
|
|
|
const free = await prisma.event.create({
|
|
data: {
|
|
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
|
|
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
|
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
|
},
|
|
});
|
|
const paid = await prisma.event.create({
|
|
data: {
|
|
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
|
|
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
|
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
|
|
},
|
|
});
|
|
|
|
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
|
|
const todayStart = new Date(now - 30 * 60 * 1000);
|
|
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
|
|
const startsAt = new Date(todayStart.getTime() + days * D);
|
|
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
|
|
});
|
|
const course = await prisma.event.create({
|
|
data: {
|
|
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
|
|
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
|
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
|
certificatesEnabled: true,
|
|
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
|
|
sessions: { create: sessions },
|
|
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
|
|
},
|
|
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
|
});
|
|
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
|
|
|
|
// A drop-in class: three upcoming classes, booked per class (the first is full).
|
|
const classes = [1, 8, 15].map((days, i) => {
|
|
const startsAt = at(days, 16, 0);
|
|
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
|
|
});
|
|
const dropIn = await prisma.event.create({
|
|
data: {
|
|
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
|
|
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
|
scheduleType: 'sessions', attendanceMode: 'pick', waitlistEnabled: true,
|
|
sessions: { create: classes },
|
|
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
|
|
},
|
|
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
|
});
|
|
// A conference sold per pass: people tick the days they'll come, one price for the pass.
|
|
const days = [2, 3, 4].map((d, i) => {
|
|
const startsAt = at(d, 7, 0);
|
|
return { label: `Day ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 8 * 60 * 60 * 1000), order: i };
|
|
});
|
|
const conference = await prisma.event.create({
|
|
data: {
|
|
title: 'E2E Conference', description: 'Three days; come to the days you can.', startDate: days[0].startsAt, endDate: days[2].endsAt,
|
|
price: 200, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
|
scheduleType: 'sessions', attendanceMode: 'pick',
|
|
sessions: { create: days },
|
|
eventOptions: { create: [{ name: 'Conference pass', price: 200, isMainTicket: true, sessionMode: 'all' }] },
|
|
},
|
|
});
|
|
|
|
// Someone else already has the only place in class 1.
|
|
await prisma.registration.create({
|
|
data: {
|
|
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
|
|
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
|
|
},
|
|
});
|
|
|
|
// Thandi is booked on the course and was checked in for week 1.
|
|
const reg = await prisma.registration.create({
|
|
data: {
|
|
userId: users.user.id, eventId: course.id, status: 'paid',
|
|
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
|
|
},
|
|
});
|
|
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
|
|
await generateTicketsForRegistration(reg.id);
|
|
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
|
|
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
|
|
|
|
const out = {
|
|
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
|
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
|
|
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
|
// An account that has never answered the news question (asked once after login).
|
|
newsPromptUser: await (async () => {
|
|
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
|
|
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
|
})(),
|
|
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
|
|
// ago (so the next run anonymises it) — and three-year-old login history.
|
|
retention: await (async () => {
|
|
const longAgo = new Date(Date.now() - 6 * 365 * 24 * 60 * 60 * 1000);
|
|
const oldUser = (email, warnedAt) => prisma.user.create({ data: { name: `Old ${email}`, email, role: 'user', password, isActive: true, createdAt: longAgo, updatedAt: longAgo, inactivityWarnedAt: warnedAt } });
|
|
const toWarn = await oldUser('dormant@e2e.test', null);
|
|
const toAnonymise = await oldUser('gone-quiet@e2e.test', new Date(Date.now() - 40 * 24 * 60 * 60 * 1000));
|
|
await prisma.securityEvent.create({ data: { userId: toWarn.id, type: 'login', createdAt: new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000) } });
|
|
return { toWarn: toWarn.id, toAnonymise: toAnonymise.id };
|
|
})(),
|
|
// The link a news email to the seeded user would carry (signed with JWT_SECRET).
|
|
unsubscribeToken: require(path.join(backend, 'src/utils/privacy')).unsubscribeToken({ userId: users.user.id }),
|
|
};
|
|
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
|
|
await prisma.$disconnect?.();
|
|
}
|
|
|
|
(async () => {
|
|
const url = e2eDatabaseUrl();
|
|
await recreateDatabase(url);
|
|
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
|
|
await seed(url);
|
|
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
|
|
process.exit(0);
|
|
})().catch((e) => {
|
|
console.error('[e2e] Preparing the database failed:', e);
|
|
process.exit(1);
|
|
});
|
|
|