mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(admin): wipe every account's data, not nine tables
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.
This commit is contained in:
1 parent
93980ac7a1
commit
ef35fe681d
15 files changed
+966
-484
No files matched your search
@@ -1,24 +1,44 @@
|
||||
/**
|
||||
* Integration regression guard for `DELETE /api/admin/data`.
|
||||
* Integration guard for `DELETE /api/admin/data` — the global "wipe all data".
|
||||
*
|
||||
* The admin "wipe all data" path nulls personal columns on `User` and
|
||||
* deletes a curated list of personal-data tables in one transaction.
|
||||
* v1.4.6 left three notification-channel-style tables out of that
|
||||
* list, leaving encrypted Telegram bot tokens (NotificationChannel.config)
|
||||
* and Web Push endpoints behind. This test is the regression guard
|
||||
* that the wipe now extends to:
|
||||
* 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.
|
||||
*
|
||||
* - notification_channels (Telegram/ntfy/web-push channel config)
|
||||
* - push_subscriptions (raw browser push endpoints)
|
||||
* - telegram_scheduled_deletions (queued message-delete tickets)
|
||||
* 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:
|
||||
*
|
||||
* AuditLog rows are intentionally PRESERVED — a wipe must not erase the
|
||||
* audit trail of the operation itself.
|
||||
* - 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
|
||||
@@ -56,19 +76,89 @@ beforeEach(async () => {
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("DELETE /api/admin/data wipe scope", () => {
|
||||
it("clears notification channels, push subscriptions, telegram deletions; preserves AuditLog", async () => {
|
||||
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();
|
||||
|
||||
// ── arrange ──
|
||||
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,
|
||||
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.
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
@@ -77,65 +167,155 @@ describe("DELETE /api/admin/data wipe scope", () => {
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
// Seed the channels & adjacent rows the wipe must clear.
|
||||
await prisma.notificationChannel.create({
|
||||
// ── 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";
|
||||
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: {
|
||||
userId: admin.id,
|
||||
type: "TELEGRAM",
|
||||
enabled: true,
|
||||
// Pretend-encrypted blob — content is irrelevant, presence is.
|
||||
config: "encrypted:test",
|
||||
username: "wipe-admin-unconfirmed",
|
||||
email: "wipe-admin-unconfirmed@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
await prisma.pushSubscription.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
endpoint: "https://push.example/sub-" + admin.id,
|
||||
p256dh: "p256dh-enc",
|
||||
auth: "auth-enc",
|
||||
},
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
await prisma.telegramScheduledDeletion.create({
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
chatId: "1234",
|
||||
messageId: 42,
|
||||
deleteAfter: new Date(Date.now() + 60_000),
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// ── act: invoke the route handler exactly as Next.js would ──
|
||||
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" }),
|
||||
});
|
||||
const res = await DELETE(req as unknown as Parameters<typeof DELETE>[0]);
|
||||
expect(res.status).toBe(200);
|
||||
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],
|
||||
);
|
||||
|
||||
// ── assert: notification-related tables are empty ──
|
||||
expect(
|
||||
await prisma.notificationChannel.count({ where: { userId: admin.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.pushSubscription.count({ where: { userId: admin.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.telegramScheduledDeletion.count({
|
||||
where: { userId: admin.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ── assert: AuditLog rows survive ──
|
||||
// The wipe writes admin.data.clear.start AND admin.data.clear audit
|
||||
// entries. They must outlive the operation (audit log is immune).
|
||||
const auditCount = await prisma.auditLog.count({
|
||||
where: { userId: admin.id },
|
||||
});
|
||||
expect(auditCount).toBeGreaterThanOrEqual(2);
|
||||
expect(res.status).toBe(422);
|
||||
expect(await prisma.measurement.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@
|
||||
* 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.
|
||||
* 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;
|
||||
@@ -25,20 +26,17 @@
|
||||
* 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 { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder";
|
||||
import {
|
||||
INSTANCE_SCOPED,
|
||||
USER_KEPT_FIELDS,
|
||||
USER_RESET,
|
||||
WIPE_EXEMPT,
|
||||
WIPE_MODELS,
|
||||
WIPE_OWNER_FIELDS,
|
||||
} from "@/lib/data-wipe/wipe-plan";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
@@ -71,248 +69,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns that bind a row of `model` to an account, as SQL identifiers.
|
||||
*
|
||||
* `user_id` is the convention. A model that relates two accounts cannot use
|
||||
* it, and the wipe plan already declares its columns — read them from there
|
||||
* rather than keeping a second list here, or the two would answer differently
|
||||
* and this file would be asserting against its own idea of ownership instead
|
||||
* of the one the route uses.
|
||||
*/
|
||||
function ownerColumns(model: string | undefined): string[] {
|
||||
const declared = model ? WIPE_OWNER_FIELDS[model] : undefined;
|
||||
if (!declared) return ["user_id"];
|
||||
return declared.map((field) =>
|
||||
field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function countRows(
|
||||
prisma: PrismaClient,
|
||||
table: string,
|
||||
userId?: string,
|
||||
model?: string,
|
||||
): Promise<number> {
|
||||
const predicate = ownerColumns(model)
|
||||
.map((column) => `"${column}" = $1`)
|
||||
.join(" OR ");
|
||||
const rows = userId
|
||||
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
||||
`SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`,
|
||||
userId,
|
||||
)
|
||||
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
||||
`SELECT count(*)::bigint AS n FROM "${table}"`,
|
||||
);
|
||||
return Number(rows[0].n);
|
||||
}
|
||||
|
||||
async function managedProfileLockIsHeld(
|
||||
prisma: PrismaClient,
|
||||
profileId: string,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Schema-driven seeding + counting for the two wipe integration guards.
|
||||
*
|
||||
* The whole defect class here is about rows that SURVIVE, so a mocked Prisma
|
||||
* proves nothing: it can only show that the calls a route makes were made.
|
||||
* Both guards therefore seed a row into every table in the schema, run their
|
||||
* wipe against real Postgres, and then ask 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 here rather than in either test file because the account wipe and
|
||||
* the admin wipe have to be measured with the same ruler. Two copies would
|
||||
* drift, and the copy that drifted would be the one whose route was wrong.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { WIPE_OWNER_FIELDS } from "@/lib/data-wipe/wipe-plan";
|
||||
|
||||
// ── schema.prisma model → table name ────────────────────────────────────────
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns that bind a row of `model` to an account, as SQL identifiers.
|
||||
*
|
||||
* `user_id` is the convention. A model that relates two accounts cannot use
|
||||
* it, and the wipe plan already declares its columns — read them from there
|
||||
* rather than keeping a second list here, or the two would answer differently
|
||||
* and this file would be asserting against its own idea of ownership instead
|
||||
* of the one the route uses.
|
||||
*/
|
||||
export function ownerColumns(model: string | undefined): string[] {
|
||||
const declared = model ? WIPE_OWNER_FIELDS[model] : undefined;
|
||||
if (!declared) return ["user_id"];
|
||||
return declared.map((field) =>
|
||||
field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function countRows(
|
||||
prisma: PrismaClient,
|
||||
table: string,
|
||||
userId?: string,
|
||||
model?: string,
|
||||
): Promise<number> {
|
||||
const predicate = ownerColumns(model)
|
||||
.map((column) => `"${column}" = $1`)
|
||||
.join(" OR ");
|
||||
const rows = userId
|
||||
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
||||
`SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`,
|
||||
userId,
|
||||
)
|
||||
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
|
||||
`SELECT count(*)::bigint AS n FROM "${table}"`,
|
||||
);
|
||||
return Number(rows[0].n);
|
||||
}
|
||||
Reference in new issue
Block a user