Files
hope-events/e2e/privacy-notice.spec.ts
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

92 lines
5.5 KiB
TypeScript

import { test, expect, seed, loginAs, api, API, type Role } from "./fixtures";
type Section = { id: string; title: string; body: string };
type Draft = { sections: Section[]; versions: { version: number; acceptances: number }[] };
type Notice = { version: number; builtIn: boolean; sections: Section[] };
type Operator = { id: string; name: string; active: boolean };
type AccessLog = { rows: { action: string; what: string; records: number | null; actor: string }[] };
type ConsentLog = { rows: { version?: number; source: string; email: string | null }[] };
test.describe("privacy notice, operators and access log (POPIA)", () => {
// A version that needs accepting is published below; accept it for every seeded account
// afterwards so later test files don't meet the prompt.
test.afterAll(async ({ request }) => {
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
for (const token of tokens) {
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
}
});
test("the built-in notice is shown until a version is published, with the operator register filled in", async ({ page }) => {
const notice = await (await page.request.get(`${API}/api/privacy/notice`)).json() as Notice;
expect(notice.version).toBe(0);
expect(notice.sections.find(s => s.id === "sharing")?.body).toContain("• PayFast (South Africa)");
await page.goto("/legal/privacy");
await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible();
await expect(page.getByText("• PayFast (South Africa)", { exact: false })).toBeVisible();
});
test("an admin edits and publishes; operators feed the notice", async ({ page }) => {
// A new operator appears in the notice's {operators} list.
const op = await api<Operator>(page, "admin", "POST", "/api/privacy/operators", { name: "E2E Mailer", purpose: "Sending emails", dataShared: "Email address", location: "Ireland" });
await loginAs(page, "admin");
await page.goto("/dashboard/admin/privacy?tab=notice");
const intro = page.getByLabel("1. Introduction text");
await expect(intro).toBeVisible();
await intro.fill("{org} looks after your information carefully. (E2E edit)");
await page.getByRole("button", { name: "Save draft" }).click();
await expect(page.getByText("Draft saved.")).toBeVisible();
// Nothing public changes until it's published.
expect(((await (await page.request.get(`${API}/api/privacy/notice`)).json()) as Notice).version).toBe(0);
await page.getByRole("button", { name: "Publish…" }).click();
await page.getByPlaceholder("e.g. We now use PayFast for online payments.").fill("Clearer introduction.");
await page.getByRole("button", { name: "Publish", exact: true }).click();
await expect(page.getByText("Version 1 is published.")).toBeVisible();
await page.goto("/legal/privacy");
await expect(page.getByText("E2E Church looks after your information carefully. (E2E edit)")).toBeVisible();
await expect(page.getByText("• E2E Mailer (Ireland) — Sending emails.", { exact: false })).toBeVisible();
await expect(page.getByText(/Version 1/).first()).toBeVisible();
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
});
test("a significant change is accepted once, after login", async ({ page }) => {
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
// Re-acceptance needs a summary of what changed.
await expect(api(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true })).rejects.toThrow(/400/);
const published = await api<{ version: number }>(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true, changeSummary: "We added WhatsApp news." });
await loginAs(page, "user");
await page.goto("/dashboard/user");
const dialog = page.getByRole("dialog", { name: "We've updated our privacy notice" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("We added WhatsApp news.")).toBeVisible();
await dialog.getByRole("button", { name: "I accept" }).click();
await expect(dialog).toBeHidden();
await page.reload();
await expect(page.getByText("My Events").first()).toBeVisible();
await expect(page.getByRole("dialog", { name: "We've updated our privacy notice" })).toHaveCount(0);
const log = await api<ConsentLog>(page, "admin", "GET", `/api/privacy/consent-log?type=notice&q=${encodeURIComponent(seed().users.user.email)}`);
expect(log.rows[0]).toMatchObject({ version: published.version, source: "prompt" });
});
test("bulk exports are in the access log", async ({ page }) => {
const res = await page.request.post(`${API}/api/reports/pdf`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}`, "Content-Type": "application/json" },
data: JSON.stringify({ title: "E2E attendee list", kind: "table", table: { columns: ["Name"], rows: [["A"], ["B"], ["C"]] } }),
});
expect(res.ok()).toBe(true);
await api(page, "supervisor", "GET", `/api/consent/events/${seed().events.free}`);
const log = await api<AccessLog>(page, "admin", "GET", "/api/privacy/access-log");
expect(log.rows.find(r => r.action === "report_pdf")).toMatchObject({ what: "E2E attendee list", records: 3, actor: "Sam Supervisor" });
expect(log.rows.some(r => r.action === "consent_list")).toBe(true);
});
});