- Privacy page: news opt-in per channel with history, download my data (JSON + PDF), correction and deletion requests - Bulk messages are an Event update or News & announcements; news only reaches people who opted in, with unsubscribe links, one-click List-Unsubscribe headers and "Reply STOP" on WhatsApp - WhatsApp STOP via WAWP's message webhook, checked against a secret in the webhook URL - Sign-up opt-in box; existing accounts kept opted in and asked once - Admin -> Data requests with 30-day due dates and a daily reminder job - Deleting an account is a request; anonymising now covers attendees, form answers, consent details, payer details in provider payloads, etc. - Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
112 lines
6.0 KiB
TypeScript
112 lines
6.0 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { test, expect, seed, loginAs, api, API } from "./fixtures";
|
|
|
|
type Account = { id: string; token: string; email: string };
|
|
type PrivacyInfo = { news: { email: boolean; whatsapp: boolean }; history: { source: string }[]; requests: { type: string; status: string }[] };
|
|
type Preview = { kind: string; matched: number; skipped: number };
|
|
type Requests = { requests: { id: string; type: string; userId: string; blockers: unknown[] }[] };
|
|
|
|
/** A brand-new account made through sign-up, so tests can change it freely. */
|
|
async function signUp(page: Page, newsOptIn: boolean): Promise<Account> {
|
|
const email = `privacy-${Date.now()}-${Math.floor(Math.random() * 1e6)}@e2e.test`;
|
|
const res = await page.request.post(`${API}/api/users`, {
|
|
data: { name: "Pat Privacy", email, password: "E2e-password-123", newsOptIn },
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
return { id: body.id, token: body.token, email };
|
|
}
|
|
|
|
async function asAccount<T>(page: Page, account: Account, method: string, url: string, body?: unknown) {
|
|
const res = await page.request.fetch(`${API}${url}`, {
|
|
method,
|
|
headers: { Authorization: `Bearer ${account.token}`, "Content-Type": "application/json" },
|
|
data: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
return { status: res.status(), body: (await res.json().catch(() => null)) as T };
|
|
}
|
|
|
|
test.describe("privacy (POPIA)", () => {
|
|
test("sign-up records the news choice; the privacy page changes it and downloads my data", async ({ page }) => {
|
|
const me = await signUp(page, false);
|
|
const before = await asAccount<PrivacyInfo>(page, me, "GET", "/api/privacy/me");
|
|
expect(before.body.news.email).toBe(false);
|
|
expect(before.body.history.map((h) => h.source)).toContain("signup");
|
|
|
|
await page.addInitScript((token) => localStorage.setItem("hope_events_token", token), me.token);
|
|
await page.goto("/dashboard/user/privacy");
|
|
const byEmail = page.getByRole("checkbox", { name: "By email" });
|
|
await expect(byEmail).not.toBeChecked();
|
|
await byEmail.check();
|
|
await expect(page.getByText("You're subscribed.")).toBeVisible();
|
|
await expect(byEmail).toBeChecked();
|
|
|
|
const exported = await asAccount<{ account: { email: string }; communication: { news: { email: boolean } } }>(page, me, "GET", "/api/privacy/me/export");
|
|
expect(exported.status).toBe(200);
|
|
expect(exported.body.account.email).toBe(me.email);
|
|
expect(exported.body.communication.news.email).toBe(true);
|
|
});
|
|
|
|
test("news broadcasts skip people who aren't opted in; event updates don't", async ({ page }) => {
|
|
const out = await signUp(page, false);
|
|
const news = await api<Preview>(page, "supervisor", "POST", "/api/broadcasts/preview", { userIds: [out.id, seed().users.admin.id], emails: "", kind: "news" });
|
|
expect(news.matched).toBe(1);
|
|
expect(news.skipped).toBe(1);
|
|
const update = await api<Preview>(page, "supervisor", "POST", "/api/broadcasts/preview", { userIds: [out.id, seed().users.admin.id], emails: "", kind: "update" });
|
|
expect(update.matched).toBe(2);
|
|
expect(update.skipped).toBe(0);
|
|
});
|
|
|
|
test("the unsubscribe link turns news off", async ({ page }) => {
|
|
await page.goto(`/unsubscribe?t=${encodeURIComponent(seed().unsubscribeToken)}`);
|
|
await page.getByRole("button", { name: "Unsubscribe from all news" }).click();
|
|
await expect(page.getByText(/You're unsubscribed from news and announcements/)).toBeVisible();
|
|
const after = await api<PrivacyInfo>(page, "user", "GET", "/api/privacy/me");
|
|
expect(after.news).toEqual({ email: false, whatsapp: false });
|
|
expect(after.history.some((h) => h.source === "unsubscribe_link")).toBe(true);
|
|
|
|
const bad = await page.request.get(`${API}/api/privacy/unsubscribe/not-a-real-token`);
|
|
expect(bad.status()).toBe(404);
|
|
});
|
|
|
|
test("accounts that never answered are asked once after logging in", async ({ page }) => {
|
|
await page.addInitScript((token) => localStorage.setItem("hope_events_token", token), seed().newsPromptUser.token);
|
|
await page.goto("/dashboard/user");
|
|
const dialog = page.getByRole("dialog", { name: "Hear about upcoming events?" });
|
|
await expect(dialog).toBeVisible();
|
|
await dialog.getByRole("button", { name: "No thanks" }).click();
|
|
await expect(dialog).toBeHidden();
|
|
|
|
await page.reload();
|
|
await expect(page.getByText("My Events").first()).toBeVisible();
|
|
await expect(page.getByRole("dialog", { name: "Hear about upcoming events?" })).toHaveCount(0);
|
|
});
|
|
|
|
test("delete my account: closed at once, anonymised when an admin completes the request", async ({ page }) => {
|
|
const me = await signUp(page, true);
|
|
const wrong = await asAccount(page, me, "POST", "/api/privacy/me/requests", { type: "deletion", password: "nope" });
|
|
expect(wrong.status).toBe(400);
|
|
const made = await asAccount(page, me, "POST", "/api/privacy/me/requests", { type: "deletion", password: "E2e-password-123" });
|
|
expect(made.status).toBe(201);
|
|
|
|
// The account is closed: the old session no longer works, and logging in is refused.
|
|
expect((await asAccount(page, me, "GET", "/api/privacy/me")).status).toBe(401);
|
|
const login = await page.request.post(`${API}/api/users/login`, { data: { email: me.email, password: "E2e-password-123" } });
|
|
expect(login.ok()).toBe(false);
|
|
|
|
const inbox = await api<Requests>(page, "admin", "GET", "/api/privacy/requests?status=open");
|
|
const request = inbox.requests.find((r) => r.userId === me.id);
|
|
expect(request?.type).toBe("deletion");
|
|
expect(request?.blockers).toEqual([]);
|
|
|
|
// The admin page shows it; completing it removes the personal information.
|
|
await loginAs(page, "admin");
|
|
await page.goto("/dashboard/admin/data-requests");
|
|
await expect(page.getByText(me.email)).toBeVisible();
|
|
await api(page, "admin", "POST", `/api/privacy/requests/${request!.id}/complete`, {});
|
|
const user = await api<{ name: string; email: string }>(page, "admin", "GET", `/api/users/${me.id}`);
|
|
expect(user.name).toBe("Deleted User");
|
|
expect(user.email).toMatch(/@deleted\.invalid$/);
|
|
});
|
|
});
|