- 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
92 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
});
|