From dccd2f320baf090e46be3b8b4c8a77eb0a3e1a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 02:14:07 +0200 Subject: [PATCH] test(settings): prove the wipe against real Postgres, and state what deletion means 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. --- docs/self-hosting/reproductive-health-data.md | 25 + src/__tests__/data-wipe-completeness.test.ts | 2 +- src/app/api/allergies/[id]/route.ts | 4 +- .../api/measurement-reminders/[id]/route.ts | 5 +- src/app/api/settings/data/route.ts | 5 +- src/lib/data-wipe/wipe-plan.ts | 2 - tests/integration/settings-data-wipe.test.ts | 522 ++++++++++++++++++ .../soft-delete-copy-parity.test.ts | 120 ++++ 8 files changed, 678 insertions(+), 7 deletions(-) create mode 100644 tests/integration/settings-data-wipe.test.ts create mode 100644 tests/integration/soft-delete-copy-parity.test.ts diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 051c5bdfd..557f96633 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -81,6 +81,31 @@ These are cited as factual public record to explain _why_ the "who holds the data" question is the one that matters for this category. HealthLog makes no claim about any other product's current practices. +## What deleting actually does + +Worth knowing before you rely on it, because "deleted" means two +different things depending on which button you press. + +- **Deleting one cycle or day-log entry** hides it. Every read filters + it out and it is gone from the app, from exports, and from the doctor + report. The row itself stays in PostgreSQL as a tombstone, which is + what lets a phone that was offline at delete time learn that the entry + went away. No job removes cycle tombstones on a schedule, so the row + stays until one of the two actions below. +- **Settings → Delete All Data** removes the rows outright, tombstones + included. Everything the account owns goes except what you sign in + with; the confirmation dialog lists it. +- **Deleting the account** removes the account row, and the database + cascades every record with it. + +Two consequences follow, and they matter for the seizure threat model +above. A host imaged while a per-entry deletion is the only deletion you +have performed still holds that entry. And a backup taken before any of +these actions still holds whatever it captured, because a backup is a +copy of a moment. If your reason for deleting is that the data must not +be recoverable from the machine, use one of the two whole-record actions +and rotate your backups. + ## Practical hardening If reproductive-health privacy is the reason you self-host, the diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 98315046d..58f9cf752 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -169,7 +169,7 @@ describe("data-wipe completeness", () => { } const orphans = [...models] - .filter(([name, shape]) => !shape.scalars.includes("userId")) + .filter(([, shape]) => !shape.scalars.includes("userId")) .map(([name]) => name) .filter( (name) => diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index 10ee3ccb2..a2591bf40 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -1,7 +1,9 @@ /** * `GET /api/allergies/{id}` — one owned, live allergy record. * `PATCH /api/allergies/{id}` — edit it (partial; field-by-field). - * `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`). + * `DELETE /api/allergies/{id}` — delete it outright (`{ deleted: true }`; 404 + * once it is gone). The confirmation dialog promises it cannot be undone, and + * nothing here needs a tombstone to keep that promise honest. * * Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`; * the body never carries it. The free-text `reaction` + `note` are encrypted diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 6f37d2fd6..e591c7481 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -2,8 +2,9 @@ * v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete. * * PATCH re-derives the server-authoritative `nextDueAt` after applying - * the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone) - * for parity with the rest of the tree. + * the (mutually-exclusive cadence) edit. DELETE removes the row; the + * confirmation dialog says the reminder is permanently deleted, and nothing + * here needs a tombstone to keep that promise honest. */ import { NextRequest } from "next/server"; diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 38a744be4..482fefb4a 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -91,7 +91,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0); // Written after the transaction commits, so the row cannot be removed by the - // AuditLog delete inside it. + // AuditLog delete inside it. The person's whole audit history goes; this one + // row stays as the receipt for the erasure they asked for. Account deletion + // takes the other branch and purges even this, because there is no longer an + // account for it to belong to. await auditLog("user.data.clear", { userId, ipAddress: getClientIp(request), diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f14986763..7ad5d753b 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -160,8 +160,6 @@ export const WIPE_MODELS = [ "ApiToken", ] as const; -export type WipeModel = (typeof WIPE_MODELS)[number]; - /** * User-scoped models this action deliberately keeps, each with the reason. * diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts new file mode 100644 index 000000000..1edbeef11 --- /dev/null +++ b/tests/integration/settings-data-wipe.test.ts @@ -0,0 +1,522 @@ +/** + * 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 { + const schema = readFileSync( + join(process.cwd(), "prisma", "schema.prisma"), + "utf8", + ); + const map = new Map(); + 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, 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 { + const columns = await prisma.$queryRaw` + SELECT table_name, column_name, is_nullable, column_default, udt_name + FROM information_schema.columns + WHERE table_schema = 'public'`; + + const foreignKeys = await prisma.$queryRaw` + 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(); + for (const row of enumRows) { + if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel); + } + + const byTable = new Map(); + 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(); + 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(); + 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>( + `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 { + const rows = userId + ? await prisma.$queryRawUnsafe>( + `SELECT count(*)::bigint AS n FROM "${table}" WHERE user_id = $1`, + userId, + ) + : await prisma.$queryRawUnsafe>( + `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; + + 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(); + }); +}); diff --git a/tests/integration/soft-delete-copy-parity.test.ts b/tests/integration/soft-delete-copy-parity.test.ts new file mode 100644 index 000000000..052cc1c63 --- /dev/null +++ b/tests/integration/soft-delete-copy-parity.test.ts @@ -0,0 +1,120 @@ +/** + * Two confirmation dialogs promised permanence over a soft delete. + * + * `records.allergies.deleteConfirmDescription` says it can't be undone and + * `measurementReminders.deleteConfirmDescription` says the reminder is + * permanently deleted, while both handlers stamped `deletedAt` and kept the + * row — with no restore route, no undo affordance, and no purge job to give + * the tombstone a horizon. The row then rode the nightly disaster-recovery + * backup off the host, indefinitely. + * + * Neither tombstone bought anything: neither model is in the + * `/api/sync/changes` delta feed, nothing references either table, and every + * read already filtered `deletedAt: null`. So the behaviour moved to match + * the copy rather than the copy retreating. + * + * Asserted against the real database because the whole question is whether a + * row is still there. `src/__tests__/copy-matches-behaviour-guard.test.ts` + * holds the other end — that the copy keeps claiming permanence. + * + * Mutation check: put either handler back on `update({ data: { deletedAt } })` + * and the matching test goes red on the surviving row. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +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), +})); + +async function signIn(username: string): Promise { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, email: `${username}@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); + return user.id; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("deletes that promise permanence remove the row", () => { + it("an allergy is gone from the database, not hidden", async () => { + const prisma = getPrismaClient(); + const userId = await signIn("allergy-deleter"); + + const allergy = await prisma.allergy.create({ + data: { userId, substance: "Penicillin" }, + }); + + const { DELETE } = await import("@/app/api/allergies/[id]/route"); + const response = await DELETE( + new Request(`http://localhost/api/allergies/${allergy.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: allergy.id }) } as never, + ); + expect(response.status).toBe(200); + + expect(await prisma.allergy.count({ where: { id: allergy.id } })).toBe(0); + }); + + it("a checkup reminder is gone from the database, not hidden", async () => { + const prisma = getPrismaClient(); + const userId = await signIn("reminder-deleter"); + + const reminder = await prisma.measurementReminder.create({ + data: { + userId, + label: "Dental checkup", + intervalDays: 180, + notifyHour: 9, + nextDueAt: new Date(Date.now() + 86_400_000), + }, + }); + + const { DELETE } = + await import("@/app/api/measurement-reminders/[id]/route"); + const response = await DELETE( + new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + expect(response.status).toBe(200); + + expect( + await prisma.measurementReminder.count({ where: { id: reminder.id } }), + ).toBe(0); + }); +});