mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Integration guard for `DELETE /api/admin/data` — the global "wipe all data".
|
|
*
|
|
* This is the wipe an operator runs before handing a box on, and it deleted
|
|
* nine tables. The list was inline, written when nine was most of the schema,
|
|
* and it stayed at nine while the schema grew past a hundred: laboratory
|
|
* results, documents, cycle logs, journals, mood, workouts, sleep and Coach
|
|
* conversations all survived a confirmation typed by hand. The previous
|
|
* version of this file asserted the three tables a v1.4.6 fix had added and
|
|
* was green the whole time — it could only see the tables it named.
|
|
*
|
|
* So it now works the way the account-wipe guard does: seed a row into every
|
|
* table in the schema, for two different accounts, run the wipe, and ask the
|
|
* database what is left. What it asserts:
|
|
*
|
|
* - every model in `ADMIN_WIPE_MODELS` is empty across the WHOLE table, not
|
|
* merely for the admin who pressed the button;
|
|
* - every table reached only through a database cascade is empty too;
|
|
* - the exempt sets still hold their rows, so the "kept" half of the
|
|
* confirmation copy is true;
|
|
* - the instance-scoped tables are untouched;
|
|
* - every `User` row survives with `USER_RESET` applied;
|
|
* - the audit history goes and exactly one receipt row replaces it.
|
|
*
|
|
* Its honest limit: it proves the wipe removes what the plan declares. The
|
|
* plan being right is what `src/__tests__/admin-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 {
|
|
ADMIN_WIPE_EXEMPT,
|
|
ADMIN_WIPE_MODELS,
|
|
INSTANCE_SCOPED,
|
|
USER_KEPT_FIELDS,
|
|
USER_RESET,
|
|
WIPE_EXEMPT,
|
|
} from "@/lib/data-wipe/wipe-plan";
|
|
|
|
// next/headers cookie + ip headers stub for getSession() inside
|
|
// requireAdmin(). The admin route reads x-forwarded-for too; we stub
|
|
// both to keep the handler reachable. Maps live in
|
|
// `mock-next-headers.ts` — see that file for the rationale (suite
|
|
// runs with vitest `isolate: false`, so per-file Maps would leak).
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function runWipe(): Promise<Response> {
|
|
const { DELETE } = await import("@/app/api/admin/data/route");
|
|
const req = new Request("http://localhost/api/admin/data", {
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-forwarded-for": "127.0.0.1",
|
|
},
|
|
body: JSON.stringify({ confirm: "DELETE ALL" }),
|
|
});
|
|
return DELETE(req as unknown as Parameters<typeof DELETE>[0]);
|
|
}
|
|
|
|
describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
|
|
it("empties every in-scope table for every account and keeps exactly the declared set", async () => {
|
|
const prisma = getPrismaClient();
|
|
const tables = modelTableMap();
|
|
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "wipe-admin",
|
|
email: "wipe-admin@example.test",
|
|
role: "ADMIN",
|
|
passwordHash: "argon2-placeholder",
|
|
heightCm: 180,
|
|
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
|
|
telegramBotToken: "secret-token",
|
|
telegramEnabled: true,
|
|
// Off, so the reset back to the default (on) is visible below.
|
|
environmentAirQualityEnabled: false,
|
|
locale: "en",
|
|
unitPreference: "metric",
|
|
},
|
|
});
|
|
|
|
// The whole point of this route is that it reaches accounts other than
|
|
// the one pressing the button. A second account's rows are seeded by
|
|
// hand across the domains the old nine-table list left behind.
|
|
const other = await prisma.user.create({
|
|
data: {
|
|
username: "wipe-bystander",
|
|
email: "wipe-bystander@example.test",
|
|
passwordHash: "argon2-placeholder",
|
|
heightCm: 165,
|
|
onboardingCompletedAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: other.id,
|
|
type: "WEIGHT",
|
|
value: 70,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.labResult.create({
|
|
data: {
|
|
userId: other.id,
|
|
analyte: "HbA1c",
|
|
value: 5.4,
|
|
unit: "%",
|
|
takenAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.coachConversation.create({
|
|
data: { userId: other.id, title: "bystander conversation" },
|
|
});
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: other.id,
|
|
sportType: "running",
|
|
startedAt: new Date("2026-01-01T06:00:00Z"),
|
|
endedAt: new Date("2026-01-01T06:30:00Z"),
|
|
durationSec: 1800,
|
|
},
|
|
});
|
|
|
|
const seeded = await seedEveryTable(prisma, admin.id);
|
|
expect(
|
|
seeded.length,
|
|
"the seeder should reach essentially the whole schema",
|
|
).toBeGreaterThan(100);
|
|
|
|
// Created after the generic seed so the cookie carries this session's id.
|
|
// The seed gives the admin a second factor, and the wipe asks for a fresh
|
|
// proof, so the session is one that has just completed it.
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: admin.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
// ── act ──
|
|
const response = await runWipe();
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as {
|
|
data: { cleared: boolean; deletedRows: number };
|
|
};
|
|
expect(body.data.cleared).toBe(true);
|
|
expect(body.data.deletedRows).toBeGreaterThan(50);
|
|
|
|
// ── assert: nothing the plan claims to delete is left, for anyone ──
|
|
const survivors: string[] = [];
|
|
for (const model of ADMIN_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.
|
|
const expected = model === "AuditLog" ? 1 : 0;
|
|
if ((await countRows(prisma, table)) > expected) {
|
|
survivors.push(`${model} (${table})`);
|
|
}
|
|
}
|
|
expect(
|
|
survivors,
|
|
`rows survived a wipe that told the operator everything was deleted:\n${survivors.join("\n")}`,
|
|
).toEqual([]);
|
|
|
|
const auditRows = await prisma.auditLog.findMany({
|
|
select: { action: true },
|
|
});
|
|
expect(auditRows.map((r) => r.action)).toEqual(["admin.data.clear"]);
|
|
|
|
// ── assert: the kept set is exactly what the copy promises ──
|
|
const missing: string[] = [];
|
|
for (const model of [
|
|
...Object.keys(WIPE_EXEMPT),
|
|
...Object.keys(ADMIN_WIPE_EXEMPT),
|
|
]) {
|
|
const table = tables.get(model)!;
|
|
if ((await countRows(prisma, table)) === 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 the data wipe`,
|
|
).toBeGreaterThan(0);
|
|
}
|
|
|
|
// ── assert: everything reached only by cascade is gone too ──
|
|
const wipedTables = new Set(ADMIN_WIPE_MODELS.map((m) => tables.get(m)!));
|
|
const keptTables = new Set(
|
|
[
|
|
...Object.keys(WIPE_EXEMPT),
|
|
...Object.keys(ADMIN_WIPE_EXEMPT),
|
|
...Object.keys(INSTANCE_SCOPED),
|
|
].map((m) => tables.get(m)!),
|
|
);
|
|
const leftovers: string[] = [];
|
|
for (const table of seeded) {
|
|
if (wipedTables.has(table)) continue;
|
|
if (keptTables.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: both accounts survive, reset but signed-in-able ──
|
|
const rows = await prisma.user.findMany({ orderBy: { username: "asc" } });
|
|
expect(rows.map((r) => r.username)).toEqual([
|
|
"wipe-admin",
|
|
"wipe-bystander",
|
|
]);
|
|
|
|
for (const row of rows) {
|
|
const record = row as unknown as Record<string, unknown>;
|
|
expect(record.passwordHash).toBe("argon2-placeholder");
|
|
for (const column of Object.keys(USER_KEPT_FIELDS)) {
|
|
expect(record).toHaveProperty(column);
|
|
}
|
|
|
|
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,
|
|
`${record.username}: User columns the plan resets still carry a value:\n${stillSet.join("\n")}`,
|
|
).toEqual([]);
|
|
}
|
|
|
|
// The admin who ran it is still signed in — the session that carried the
|
|
// request has to outlive it or the operator is logged out mid-action.
|
|
expect(await prisma.session.count({ where: { id: session.id } })).toBe(1);
|
|
});
|
|
|
|
it("refuses a wipe without the typed confirmation", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "wipe-admin-unconfirmed",
|
|
email: "wipe-admin-unconfirmed@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const { DELETE } = await import("@/app/api/admin/data/route");
|
|
const res = await DELETE(
|
|
new Request("http://localhost/api/admin/data", {
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-forwarded-for": "127.0.0.1",
|
|
},
|
|
body: JSON.stringify({ confirm: "delete all" }),
|
|
}) as unknown as Parameters<typeof DELETE>[0],
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await prisma.measurement.count()).toBe(1);
|
|
});
|
|
});
|