Files
HealthLog/tests/integration/settings-data-wipe.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

505 lines
17 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. It lives in `./wipe-seeder` because the admin wipe
* has to be measured with the same ruler.
*
* 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder";
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";
import {
claimManagedGuardianEgressAuthorization,
resolveNotificationDeliveryIdentity,
} from "@/lib/notifications/delivery-identity";
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);
},
})),
};
});
async function managedProfileLockIsHeld(
prisma: PrismaClient,
profileId: string,
): Promise<boolean> {
const rows = await prisma.$queryRaw<Array<{ locked: boolean }>>`
SELECT NOT pg_try_advisory_xact_lock(
hashtextextended(${`managed-profile:${profileId}`}, 0)
) AS locked
`;
return rows[0]?.locked === true;
}
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",
// Off, so the reset back to the default (on) is visible below.
environmentAirQualityEnabled: false,
thresholdsJson: { sys: 130 },
onboardingCompletedAt: new Date(),
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, model)) > 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" ||
// A column whose reset is a non-empty default (v1.42: the air-quality
// switch resets to on) is cleared when it holds exactly that default.
(typeof value === "boolean" &&
(USER_RESET as Record<string, unknown>)[column] === value);
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 a wipe that would remove a managed profile's last Guardian", async () => {
const prisma = getPrismaClient();
const [guardian, profile] = await Promise.all([
prisma.user.create({
data: {
username: "wipe-last-guardian",
email: "wipe-last-guardian@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-last-profile",
email: "wipe-last-profile@example.test",
managedProfileAt: new Date(),
},
}),
]);
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
});
await prisma.measurement.create({
data: {
userId: guardian.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const session = await prisma.session.create({
data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
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(409);
expect(
await prisma.accountGrant.count({
where: { grantorId: profile.id, granteeId: guardian.id },
}),
).toBe(1);
expect(
await prisma.measurement.count({ where: { userId: guardian.id } }),
).toBe(1);
});
it("makes a queued Guardian claim observe a completed data wipe", async () => {
const prisma = getPrismaClient();
const [guardian, reserveGuardian, profile] = await Promise.all([
prisma.user.create({
data: {
username: "wipe-race-guardian",
email: "wipe-race-guardian@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-race-reserve",
email: "wipe-race-reserve@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-race-profile",
email: "wipe-race-profile@example.test",
managedProfileAt: new Date(),
},
}),
]);
await Promise.all([
prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
}),
prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: reserveGuardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
}),
]);
expect(
await prisma.accountGrant.count({
where: {
grantorId: profile.id,
access: "MANAGE",
acceptedAt: { not: null },
revokedAt: null,
},
}),
).toBe(2);
const measurement = await prisma.measurement.create({
data: {
userId: guardian.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const session = await prisma.session.create({
data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const delivery = await resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: profile.id,
recordUserId: profile.id,
recipientUserId: guardian.id,
title: "record reminder",
message: "record message",
});
expect(delivery).toMatchObject({ managed: true });
let releaseMeasurement!: () => void;
let markMeasurementLocked!: () => void;
const measurementLocked = new Promise<void>((resolve) => {
markMeasurementLocked = resolve;
});
const holdMeasurement = new Promise<void>((resolve) => {
releaseMeasurement = resolve;
});
const blocker = prisma.$transaction(async (tx) => {
await tx.measurement.update({
where: { id: measurement.id },
data: { value: 81 },
});
markMeasurementLocked();
await holdMeasurement;
});
await measurementLocked;
const { DELETE } = await import("@/app/api/settings/data/route");
const wipe = DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
await vi.waitFor(async () => {
expect(await managedProfileLockIsHeld(prisma, profile.id)).toBe(true);
});
const claim = claimManagedGuardianEgressAuthorization(
delivery!,
"NTFY",
"MEDICATION_REMINDER",
);
await expectPromiseToRemainPending(claim);
releaseMeasurement();
await blocker;
expect((await wipe).status).toBe(200);
await expect(claim).resolves.toBeNull();
expect(await prisma.notificationEgressAuthorization.count()).toBe(0);
expect(await prisma.pushAttempt.count()).toBe(0);
});
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();
});
});
async function expectPromiseToRemainPending(promise: Promise<unknown>) {
const settled = await Promise.race([
promise.then(
() => true,
() => true,
),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25)),
]);
expect(settled).toBe(false);
}