mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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.
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
/**
|
|
* Structural guard binding `prisma/schema.prisma` to the ADMIN wipe.
|
|
*
|
|
* The per-account wipe grew a derived plan and a completeness test after it
|
|
* was found deleting thirteen tables out of eighty-odd. The admin wipe — the
|
|
* one an operator runs before handing a box on, "Deletes all data for all
|
|
* users" — kept its inline list of nine `deleteMany` calls, written when nine
|
|
* was most of the schema. It was still nine against a hundred and twenty-two
|
|
* models: laboratory results, documents, cycle logs, journals, mood, workouts,
|
|
* sleep and Coach conversations all survived a confirmation the operator typed
|
|
* by hand. Nothing failed, because nothing was asking.
|
|
*
|
|
* So the route reads `ADMIN_WIPE_MODELS` — `WIPE_MODELS` minus the entries in
|
|
* `ADMIN_WIPE_EXEMPT` — and this file asks the question its sibling asks for
|
|
* the account wipe.
|
|
*
|
|
* What it proves:
|
|
* 1. every model carrying a `userId` column is deleted by the admin wipe or
|
|
* excused by a named list with a written reason;
|
|
* 2. every model without one either reaches an admin-wiped model through a
|
|
* chain of required `onDelete: Cascade` relations or is declared
|
|
* instance-scoped;
|
|
* 3. every admin-only exemption names a real model that the per-account wipe
|
|
* DOES delete, so the entry is a deliberate subtraction rather than a
|
|
* stale name nobody re-read;
|
|
* 4. the route carries no table list of its own — it iterates the plan.
|
|
*
|
|
* Its honest limit is the same as its sibling's: it proves classification, not
|
|
* judgement. It cannot tell that a model was excused for a bad reason, only
|
|
* that somebody had to write the reason down. And rule 4 is source-shaped: it
|
|
* reads the route file for `deleteMany` calls on a literal delegate, so a
|
|
* future inline list built some other way would slip it.
|
|
*
|
|
* Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming
|
|
* that model as unclassified; put an inline `tx.measurement.deleteMany({})`
|
|
* back into the route and rule 4 goes red naming it.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
parseWipeSchema,
|
|
reachedByCascade,
|
|
} from "@/__tests__/helpers/wipe-schema-shape";
|
|
import {
|
|
ADMIN_WIPE_EXEMPT,
|
|
ADMIN_WIPE_MODELS,
|
|
INSTANCE_SCOPED,
|
|
WIPE_EXEMPT,
|
|
WIPE_MODELS,
|
|
} from "@/lib/data-wipe/wipe-plan";
|
|
|
|
const ROUTE = join(
|
|
process.cwd(),
|
|
"src",
|
|
"app",
|
|
"api",
|
|
"admin",
|
|
"data",
|
|
"route.ts",
|
|
);
|
|
|
|
const models = parseWipeSchema();
|
|
const adminWipeSet = new Set<string>(ADMIN_WIPE_MODELS);
|
|
|
|
/** Every list that is allowed to answer "why did this model survive?". */
|
|
function survivorReason(model: string): string | null {
|
|
return (
|
|
ADMIN_WIPE_EXEMPT[model] ??
|
|
WIPE_EXEMPT[model] ??
|
|
INSTANCE_SCOPED[model] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
describe("admin data-wipe completeness", () => {
|
|
it("deletes essentially the whole user-scoped schema", () => {
|
|
// A floor, not a target. The defect this file exists for was a wipe that
|
|
// covered nine tables while reporting success; a silent shrink back
|
|
// towards that number fails here even if every survivor is classified.
|
|
expect(ADMIN_WIPE_MODELS.length).toBeGreaterThan(80);
|
|
});
|
|
|
|
it("every model with a userId column is wiped or excused with a reason", () => {
|
|
const userScoped = [...models]
|
|
.filter(([, shape]) => shape.scalars.includes("userId"))
|
|
.map(([name]) => name);
|
|
|
|
expect(userScoped.length).toBeGreaterThan(80);
|
|
|
|
const unclassified = userScoped.filter(
|
|
(name) => !adminWipeSet.has(name) && survivorReason(name) === null,
|
|
);
|
|
|
|
if (unclassified.length > 0) {
|
|
throw new Error(
|
|
`${unclassified.length} user-scoped model(s) survive the admin wipe with nothing saying why:\n` +
|
|
unclassified.map((n) => ` ❌ ${n}`).join("\n") +
|
|
"\n\nAn operator who types DELETE ALL before handing this server on " +
|
|
"expects these rows gone. Add each to WIPE_MODELS, or to " +
|
|
"ADMIN_WIPE_EXEMPT / WIPE_EXEMPT / INSTANCE_SCOPED with the reason " +
|
|
"keeping it is not keeping somebody's record.",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
|
|
const covered = reachedByCascade(models, adminWipeSet);
|
|
|
|
const orphans = [...models]
|
|
.filter(([, shape]) => !shape.scalars.includes("userId"))
|
|
.map(([name]) => name)
|
|
.filter((name) => !covered.has(name) && survivorReason(name) === null);
|
|
|
|
if (orphans.length > 0) {
|
|
throw new Error(
|
|
`${orphans.length} model(s) carry no userId, are not removed by any cascade from an admin-wiped row, and are not declared instance-scoped:\n` +
|
|
orphans.map((n) => ` ❌ ${n}`).join("\n"),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("excuses only models the per-account wipe does delete, each with a reason", () => {
|
|
for (const [name, reason] of Object.entries(ADMIN_WIPE_EXEMPT)) {
|
|
expect(
|
|
models.has(name),
|
|
`ADMIN_WIPE_EXEMPT names "${name}", which is not a model`,
|
|
).toBe(true);
|
|
// The list exists to record where the two wipes DIFFER. A name the
|
|
// account wipe does not delete either is not a difference; it is a
|
|
// stale entry hiding behind a plausible reason.
|
|
expect(
|
|
(WIPE_MODELS as readonly string[]).includes(name),
|
|
`ADMIN_WIPE_EXEMPT names "${name}", which the per-account wipe does not delete either — the entry records no difference`,
|
|
).toBe(true);
|
|
expect(
|
|
reason.length,
|
|
`ADMIN_WIPE_EXEMPT.${name} needs a real reason`,
|
|
).toBeGreaterThan(30);
|
|
}
|
|
});
|
|
|
|
it("derives the model set instead of listing tables in the route", () => {
|
|
const source = readFileSync(ROUTE, "utf8");
|
|
|
|
expect(
|
|
source.includes("ADMIN_WIPE_MODELS"),
|
|
"the admin wipe route no longer reads the shared plan",
|
|
).toBe(true);
|
|
|
|
// `tx.measurement.deleteMany(` and friends — the shape the inline list
|
|
// had. The loop calls `resolveGlobalWipeDelegate(tx, model).deleteMany`,
|
|
// which names no table and does not match.
|
|
const inline = [
|
|
...source.matchAll(
|
|
/\b(?:tx|prisma)\s*\.\s*(\w+)\s*\.\s*deleteMany\s*\(/g,
|
|
),
|
|
].map((m) => m[1]);
|
|
|
|
expect(
|
|
inline,
|
|
`the admin wipe names tables inline again:\n${inline.join("\n")}\n\n` +
|
|
"That list is how it drifted to nine tables: it was correct the day " +
|
|
"it was written and nothing noticed when the schema moved past it. " +
|
|
"Delete through ADMIN_WIPE_MODELS.",
|
|
).toEqual([]);
|
|
});
|
|
});
|