mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
The admin "Delete all data" action carried its delete list inline. That list named nine tables, which was most of the schema on the day it was written, and it stayed at nine while the schema grew to a hundred and twenty-two models. Laboratory results, documents, cycle logs, journals, mood, workouts, sleep and Coach conversations all survived the wipe an operator runs before handing a box on, and the toast said it was done. The per-account wipe had the same defect and grew a derived plan for it. The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS minus a single documented subtraction, so a model added to the schema lands in both wipes at once. - AccountGrant is the one difference, with its reason in the plan: it is the only access path to a managed profile, and this action preserves accounts by definition. Deleting every grant would leave records nobody can open, administer or delete. - The audit history now goes with everything else. It is a record of who did what from where, which is exactly what clearing a box is for; the receipt written after the transaction commits replaces it. - One transaction with the same budget as the account wipe. A half-applied wipe is worse than none, because the person is told it succeeded either way. - The result line reports rows and tables instead of nine fixed names, which is how nobody noticed: it read the same whether the wipe had cleared nine tables or ninety. Guards, both watched red before landing: admin-data-wipe-completeness.test.ts fails when a user-scoped model reaches neither the wipe nor a written exemption, and when the route names a table inline again. The integration guard seeds every table in the schema for two accounts, runs the route, and asks the database what is left — drop one model from the plan and it names the surviving table.
504 lines
16 KiB
TypeScript
504 lines
16 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
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",
|
|
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, 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";
|
|
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);
|
|
}
|