mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The defect is about rows that survive, so a mocked client proves nothing. The new integration test seeds a row into every table in the schema, driven by information_schema and pg_constraint rather than a fixture, runs the wipe and asks the database what is left. A table added later is seeded and asserted without anyone remembering to. It holds all four halves: nothing in the wipe list survives, everything in the exemption list does, everything reached only by a database cascade is gone, and the account row comes back reset but able to sign in. A second file pins the allergy and checkup-reminder deletes actually removing their row. The reproductive-health note gains a Deletion section. It taught people to reason carefully about a host being imaged and never said what pressing delete does; now it says which of the three deletions removes the row and which only hides it.
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
/**
|
|
* Integration guard for `DELETE /api/settings/data` — "Delete All Data".
|
|
*
|
|
* The whole defect is about rows that survive, so a mocked Prisma proves
|
|
* nothing: it can only show that the calls the route makes were made. This
|
|
* runs against real Postgres, seeds a row into every table in the schema, runs
|
|
* the wipe, and then asks the database what is left.
|
|
*
|
|
* The seeding is driven by `information_schema` and `pg_constraint` rather
|
|
* than a hand-written fixture, for the same reason the wipe list is derived
|
|
* rather than hand-written: a fixture written today covers the schema of
|
|
* today. A table added next year is seeded automatically and therefore
|
|
* asserted automatically.
|
|
*
|
|
* What it asserts:
|
|
* - every model in `WIPE_MODELS` has zero rows for the account afterwards;
|
|
* - every model in `WIPE_EXEMPT` still has its row, so the "kept" half of
|
|
* the confirmation copy is true too;
|
|
* - every table the wipe reaches only through a database cascade is empty;
|
|
* - the instance-scoped tables are untouched;
|
|
* - the `User` row survives with `USER_RESET` applied and
|
|
* `USER_KEPT_FIELDS` intact.
|
|
*
|
|
* Its honest limit: it proves the wipe removes what the plan declares. The
|
|
* plan being right is what `src/__tests__/data-wipe-completeness.test.ts`
|
|
* holds against `schema.prisma`.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import {
|
|
INSTANCE_SCOPED,
|
|
USER_KEPT_FIELDS,
|
|
USER_RESET,
|
|
WIPE_EXEMPT,
|
|
WIPE_MODELS,
|
|
} from "@/lib/data-wipe/wipe-plan";
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// ── schema.prisma model → table name ────────────────────────────────────────
|
|
|
|
function modelTableMap(): Map<string, string> {
|
|
const schema = readFileSync(
|
|
join(process.cwd(), "prisma", "schema.prisma"),
|
|
"utf8",
|
|
);
|
|
const map = new Map<string, string>();
|
|
for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
|
|
const mapped = /@@map\("([^"]+)"\)/.exec(block[2]);
|
|
map.set(block[1], mapped ? mapped[1] : block[1]);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── generic seeder ──────────────────────────────────────────────────────────
|
|
|
|
interface ColumnRow {
|
|
table_name: string;
|
|
column_name: string;
|
|
is_nullable: string;
|
|
column_default: string | null;
|
|
udt_name: string;
|
|
}
|
|
|
|
interface ForeignKeyRow {
|
|
table_name: string;
|
|
column_name: string;
|
|
ref_table: string;
|
|
ref_column: string;
|
|
cascades: boolean;
|
|
}
|
|
|
|
/** Tables the seeder never writes: Prisma's own ledger, and the account row. */
|
|
const SEED_SKIP = new Set(["_prisma_migrations", "users"]);
|
|
|
|
function synthesise(udt: string, enums: Map<string, string>, tag: string) {
|
|
if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`;
|
|
switch (udt) {
|
|
case "text":
|
|
case "varchar":
|
|
case "bpchar":
|
|
return `'${tag}'`;
|
|
case "int2":
|
|
case "int4":
|
|
case "int8":
|
|
case "numeric":
|
|
case "float4":
|
|
case "float8":
|
|
return "1";
|
|
case "bool":
|
|
return "false";
|
|
case "timestamp":
|
|
case "timestamptz":
|
|
case "date":
|
|
return "now()";
|
|
case "bytea":
|
|
return "'\\x00'::bytea";
|
|
case "json":
|
|
case "jsonb":
|
|
return `'{}'::${udt}`;
|
|
case "uuid":
|
|
return "gen_random_uuid()";
|
|
default: {
|
|
const label = enums.get(udt);
|
|
if (label) return `'${label}'::"${udt}"`;
|
|
throw new Error(`seeder has no value for column type "${udt}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert one row into every table in `public`, parents first, wiring every
|
|
* foreign key to the row already created for its target and every `user_id` to
|
|
* the account under test.
|
|
*/
|
|
async function seedEveryTable(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
): Promise<string[]> {
|
|
const columns = await prisma.$queryRaw<ColumnRow[]>`
|
|
SELECT table_name, column_name, is_nullable, column_default, udt_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'public'`;
|
|
|
|
const foreignKeys = await prisma.$queryRaw<ForeignKeyRow[]>`
|
|
SELECT con.conrelid::regclass::text AS table_name,
|
|
att.attname AS column_name,
|
|
con.confrelid::regclass::text AS ref_table,
|
|
ratt.attname AS ref_column,
|
|
con.confdeltype = 'c' AS cascades
|
|
FROM pg_constraint con
|
|
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
|
|
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord)
|
|
ON rk.ord = ck.ord
|
|
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum
|
|
JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum
|
|
WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`;
|
|
|
|
const enumRows = await prisma.$queryRaw<
|
|
Array<{ typname: string; enumlabel: string }>
|
|
>`
|
|
SELECT t.typname, e.enumlabel
|
|
FROM pg_type t
|
|
JOIN pg_enum e ON e.enumtypid = t.oid
|
|
ORDER BY t.typname, e.enumsortorder`;
|
|
const enums = new Map<string, string>();
|
|
for (const row of enumRows) {
|
|
if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel);
|
|
}
|
|
|
|
const byTable = new Map<string, ColumnRow[]>();
|
|
for (const column of columns) {
|
|
if (SEED_SKIP.has(column.table_name)) continue;
|
|
const bucket = byTable.get(column.table_name) ?? [];
|
|
bucket.push(column);
|
|
byTable.set(column.table_name, bucket);
|
|
}
|
|
|
|
const fkByTable = new Map<string, ForeignKeyRow[]>();
|
|
for (const fk of foreignKeys) {
|
|
const bucket = fkByTable.get(fk.table_name) ?? [];
|
|
bucket.push(fk);
|
|
fkByTable.set(fk.table_name, bucket);
|
|
}
|
|
|
|
// Parents before children. A self-reference or an optional cycle is skipped
|
|
// by only ordering on the columns the insert actually fills.
|
|
const pending = new Set(byTable.keys());
|
|
const ordered: string[] = [];
|
|
while (pending.size > 0) {
|
|
let progressed = false;
|
|
for (const table of [...pending]) {
|
|
const blocked = (fkByTable.get(table) ?? []).some(
|
|
(fk) => fk.ref_table !== table && pending.has(fk.ref_table),
|
|
);
|
|
if (blocked) continue;
|
|
ordered.push(table);
|
|
pending.delete(table);
|
|
progressed = true;
|
|
}
|
|
if (!progressed) {
|
|
throw new Error(
|
|
`foreign-key cycle among: ${[...pending].sort().join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const insertedId = new Map<string, string>();
|
|
let tag = 0;
|
|
|
|
for (const table of ordered) {
|
|
const tableColumns = byTable.get(table)!;
|
|
const fks = fkByTable.get(table) ?? [];
|
|
const names: string[] = [];
|
|
const values: string[] = [];
|
|
|
|
for (const column of tableColumns) {
|
|
const fk = fks.find((f) => f.column_name === column.column_name);
|
|
const required =
|
|
column.is_nullable === "NO" && column.column_default === null;
|
|
const isUserColumn = column.column_name === "user_id";
|
|
|
|
if (fk?.ref_table === "users") {
|
|
// Always bind to the account under test, nullable or not — a NULL
|
|
// owner would put the row outside the wipe and make the assertion
|
|
// vacuous.
|
|
names.push(column.column_name);
|
|
values.push(`'${userId}'`);
|
|
continue;
|
|
}
|
|
if (!required && !isUserColumn) continue;
|
|
if (fk) {
|
|
const parent = insertedId.get(fk.ref_table);
|
|
if (!parent) {
|
|
throw new Error(
|
|
`${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`,
|
|
);
|
|
}
|
|
names.push(column.column_name);
|
|
values.push(`'${parent}'`);
|
|
continue;
|
|
}
|
|
names.push(column.column_name);
|
|
values.push(synthesise(column.udt_name, enums, `seed-${tag++}`));
|
|
}
|
|
|
|
const sql =
|
|
names.length === 0
|
|
? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING`
|
|
: `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`;
|
|
await prisma.$executeRawUnsafe(sql);
|
|
|
|
const hasId = tableColumns.some((c) => c.column_name === "id");
|
|
if (hasId) {
|
|
const rows = await prisma.$queryRawUnsafe<Array<{ id: string }>>(
|
|
`SELECT id FROM "${table}" LIMIT 1`,
|
|
);
|
|
if (rows[0]) insertedId.set(table, rows[0].id);
|
|
}
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
async function countRows(
|
|
prisma: PrismaClient,
|
|
table: string,
|
|
userId?: string,
|
|
): Promise<number> {
|
|
const rows = userId
|
|
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
|
`SELECT count(*)::bigint AS n FROM "${table}" WHERE user_id = $1`,
|
|
userId,
|
|
)
|
|
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
|
`SELECT count(*)::bigint AS n FROM "${table}"`,
|
|
);
|
|
return Number(rows[0].n);
|
|
}
|
|
|
|
describe("DELETE /api/settings/data leaves nothing it promised to delete", () => {
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
it("wipes every in-scope table and keeps exactly the declared set", async () => {
|
|
const prisma = getPrismaClient();
|
|
const tables = modelTableMap();
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "wipe-subject",
|
|
email: "wipe-subject@example.test",
|
|
heightCm: 180,
|
|
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
|
|
gender: "OTHER",
|
|
fullName: "Seeded Name",
|
|
insurerName: "Seeded Insurer",
|
|
stravaAthleteId: "12345",
|
|
thresholdsJson: { sys: 130 },
|
|
onboardingCompletedAt: new Date(),
|
|
onboardingStep: 4,
|
|
passwordHash: "argon2-placeholder",
|
|
locale: "en",
|
|
unitPreference: "metric",
|
|
},
|
|
});
|
|
|
|
const seeded = await seedEveryTable(prisma, user.id);
|
|
expect(
|
|
seeded.length,
|
|
"the seeder should reach essentially the whole schema",
|
|
).toBeGreaterThan(100);
|
|
|
|
// The session has to be created after the generic seed so its id is the
|
|
// one the cookie carries. `mfaVerifiedAt` is set because the seeder also
|
|
// planted a WebAuthn MFA credential, which makes the account MFA-enrolled
|
|
// and puts the route behind a fresh step-up.
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
// ── act ──
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const response = await DELETE(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}) as never,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
// ── assert: nothing the plan claims to delete is left ──
|
|
const survivors: string[] = [];
|
|
for (const model of WIPE_MODELS) {
|
|
const table = tables.get(model)!;
|
|
// `AuditLog` is wiped inside the transaction and then receives exactly
|
|
// one new row after it commits: the receipt for the wipe itself. The
|
|
// history is gone; the record that the person asked for it is not.
|
|
const expected = model === "AuditLog" ? 1 : 0;
|
|
if ((await countRows(prisma, table, user.id)) > expected) {
|
|
survivors.push(`${model} (${table})`);
|
|
}
|
|
}
|
|
expect(
|
|
survivors,
|
|
`rows survived a wipe that told the person everything was deleted:\n${survivors.join("\n")}`,
|
|
).toEqual([]);
|
|
|
|
// The one surviving audit row is the receipt, not leftover history.
|
|
const auditRows = await prisma.auditLog.findMany({
|
|
where: { userId: user.id },
|
|
select: { action: true },
|
|
});
|
|
expect(auditRows.map((r) => r.action)).toEqual(["user.data.clear"]);
|
|
|
|
// ── assert: the kept set is exactly what the copy promises ──
|
|
const missing: string[] = [];
|
|
for (const model of Object.keys(WIPE_EXEMPT)) {
|
|
const table = tables.get(model)!;
|
|
if ((await countRows(prisma, table, user.id)) === 0) {
|
|
missing.push(`${model} (${table})`);
|
|
}
|
|
}
|
|
expect(
|
|
missing,
|
|
`the copy says these are kept, and they are gone:\n${missing.join("\n")}`,
|
|
).toEqual([]);
|
|
|
|
// ── assert: the instance's own tables are untouched ──
|
|
for (const model of Object.keys(INSTANCE_SCOPED)) {
|
|
if (model === "User") continue;
|
|
const table = tables.get(model)!;
|
|
expect(
|
|
await countRows(prisma, table),
|
|
`${model} belongs to the instance and must survive one account's wipe`,
|
|
).toBeGreaterThan(0);
|
|
}
|
|
|
|
// ── assert: everything reached only by cascade is gone too ──
|
|
const wipedTables = new Set(WIPE_MODELS.map((m) => tables.get(m)!));
|
|
const exemptTables = new Set(
|
|
Object.keys(WIPE_EXEMPT).map((m) => tables.get(m)!),
|
|
);
|
|
const instanceTables = new Set(
|
|
Object.keys(INSTANCE_SCOPED).map((m) => tables.get(m)!),
|
|
);
|
|
const leftovers: string[] = [];
|
|
for (const table of seeded) {
|
|
if (wipedTables.has(table)) continue;
|
|
if (exemptTables.has(table)) continue;
|
|
if (instanceTables.has(table)) continue;
|
|
if ((await countRows(prisma, table)) > 0) leftovers.push(table);
|
|
}
|
|
expect(
|
|
leftovers,
|
|
`tables the wipe reaches only through a database cascade still hold rows:\n${leftovers.join("\n")}`,
|
|
).toEqual([]);
|
|
|
|
// ── assert: the account survives, reset but signed-in-able ──
|
|
const after = await prisma.user.findUniqueOrThrow({
|
|
where: { id: user.id },
|
|
});
|
|
const record = after as unknown as Record<string, unknown>;
|
|
|
|
expect(record.username).toBe("wipe-subject");
|
|
expect(record.passwordHash).toBe("argon2-placeholder");
|
|
for (const column of Object.keys(USER_KEPT_FIELDS)) {
|
|
expect(record).toHaveProperty(column);
|
|
}
|
|
expect(record.locale).toBe("en");
|
|
expect(record.unitPreference).toBe("metric");
|
|
|
|
const stillSet: string[] = [];
|
|
for (const column of Object.keys(USER_RESET)) {
|
|
const value = record[column];
|
|
const cleared =
|
|
value === null ||
|
|
value === false ||
|
|
value === 0 ||
|
|
(Array.isArray(value) && value.length === 0) ||
|
|
value === "aggregated" ||
|
|
value === "disconnected";
|
|
if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
|
|
}
|
|
expect(
|
|
stillSet,
|
|
`User columns the plan resets still carry a value:\n${stillSet.join("\n")}`,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses without the typed confirmation and leaves the record alone", async () => {
|
|
const prisma = getPrismaClient();
|
|
|
|
const user = await prisma.user.create({
|
|
data: { username: "wipe-refused", email: "wipe-refused@example.test" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const response = await DELETE(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "nope" }),
|
|
}) as never,
|
|
);
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it("revokes a clinician share link so the token resolves to nothing", async () => {
|
|
const prisma = getPrismaClient();
|
|
|
|
const user = await prisma.user.create({
|
|
data: { username: "wipe-sharer", email: "wipe-sharer@example.test" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
const rawToken = `hls_${"a".repeat(48)}`;
|
|
await prisma.clinicianShareLink.create({
|
|
data: {
|
|
userId: user.id,
|
|
tokenHash: hashToken(rawToken),
|
|
label: "Practice",
|
|
rangeStart: new Date("2026-01-01T00:00:00Z"),
|
|
sectionsJson: {},
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
const { resolveShareToken } =
|
|
await import("@/lib/clinician-share/resolve-share-token");
|
|
expect(await resolveShareToken(rawToken)).not.toBeNull();
|
|
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const response = await DELETE(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}) as never,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
// A clinician holding the link now reads nothing, not a stale record.
|
|
expect(await resolveShareToken(rawToken)).toBeNull();
|
|
});
|
|
});
|