- 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
65 lines
3.9 KiB
TypeScript
65 lines
3.9 KiB
TypeScript
import { test, expect, seed, loginAs, api } from "./fixtures";
|
|
|
|
type RunResult = { result: Record<string, Record<string, number>> };
|
|
type UserRow = { name: string; email: string; isActive: boolean };
|
|
type Readiness = { score: number; checks: { key: string; ok: boolean; applies: boolean }[] };
|
|
type Breach = { id: string; status: string; regulatorNotifiedAt: string | null };
|
|
|
|
test.describe("retention, breaches and readiness (POPIA)", () => {
|
|
test("the retention job warns inactive accounts first, and anonymises them 30 days after the warning", async ({ page }) => {
|
|
const { result } = await api<RunResult>(page, "admin", "POST", "/api/privacy/retention/run");
|
|
expect(result.inactive_accounts.warned).toBeGreaterThanOrEqual(1);
|
|
expect(result.inactive_accounts.anonymised).toBeGreaterThanOrEqual(1);
|
|
expect(result.login_history.deleted).toBeGreaterThanOrEqual(1);
|
|
|
|
const warned = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toWarn}`);
|
|
expect(warned.email).toBe("dormant@e2e.test");
|
|
const gone = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toAnonymise}`);
|
|
expect(gone.name).toBe("Deleted User");
|
|
expect(gone.email).toMatch(/@deleted\.invalid$/);
|
|
|
|
// Seeded accounts (staff roles, and the recently active user) are untouched.
|
|
expect((await api<UserRow>(page, "admin", "GET", `/api/users/${seed().users.user.id}`)).email).toBe("user@e2e.test");
|
|
});
|
|
|
|
test("retention periods are validated and saved; the notice follows them", async ({ page }) => {
|
|
const state = await api<{ policies: Record<string, { enabled: boolean; months: number }> }>(page, "admin", "GET", "/api/privacy/retention");
|
|
await expect(api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, inactive_accounts: { enabled: true, months: 3 } } })).rejects.toThrow(/400/);
|
|
await api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, form_answers: { enabled: true, months: 18 } } });
|
|
|
|
await page.goto("/legal/privacy");
|
|
await expect(page.getByText("Answers to event forms are deleted 18 months after the event.", { exact: false })).toBeVisible();
|
|
});
|
|
|
|
test("a breach is recorded, reported, and drafts are ready to copy", async ({ page }) => {
|
|
const breach = await api<Breach>(page, "admin", "POST", "/api/privacy/breaches", {
|
|
title: "Phone with the attendee list lost", discoveredAt: new Date().toISOString(),
|
|
description: "A leader's phone was stolen at the camp site.", dataAffected: "Names and phone numbers of 40 campers",
|
|
});
|
|
expect(breach.status).toBe("open");
|
|
|
|
await loginAs(page, "admin");
|
|
await page.goto("/dashboard/admin/privacy?tab=breaches");
|
|
await page.getByRole("button", { name: /Phone with the attendee list lost/ }).click();
|
|
await expect(page.getByText("Draft message to the people affected")).toBeVisible();
|
|
await expect(page.getByText("Personal information affected: Names and phone numbers of 40 campers")).toBeVisible();
|
|
await page.getByRole("button", { name: "Regulator told today" }).click();
|
|
await expect(page.getByRole("button", { name: "Regulator told today" })).toHaveCount(0);
|
|
|
|
const list = await api<Breach[]>(page, "admin", "GET", "/api/privacy/breaches");
|
|
expect(list.find(b => b.id === breach.id)?.regulatorNotifiedAt).toBeTruthy();
|
|
});
|
|
|
|
test("the readiness page scores the install", async ({ page }) => {
|
|
const r = await api<Readiness>(page, "admin", "GET", "/api/privacy/readiness");
|
|
expect(r.score).toBeGreaterThan(0);
|
|
expect(r.checks.find(c => c.key === "retention_reviewed")?.ok).toBe(true);
|
|
expect(r.checks.find(c => c.key === "retention_running")?.ok).toBe(false); // jobs are off in e2e
|
|
|
|
await loginAs(page, "admin");
|
|
await page.goto("/dashboard/admin/privacy");
|
|
await expect(page.getByText("POPIA readiness")).toBeVisible();
|
|
await expect(page.getByText(`${r.score}%`)).toBeVisible();
|
|
});
|
|
});
|