Files
hope-events/e2e/support/prepare-db.js
T
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- 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
2026-10-23 20:25:58 +02:00

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