Merge pull request #734 from MBombeck/fix/wipe-geo-truths

The admin wipe wipes, geo stays on the host, and two docs tell the truth
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 03:54:02 +02:00
commit fec15e8fed
20 files changed
+1288 -553

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se
HealthLog Docker images are built and published from this repository's CI:
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
- **Architectures**: `linux/amd64` and `linux/arm64`
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
+14 -12
View File
@@ -3,15 +3,15 @@
The admin login overview shows a coarse `City, CC` location and a carrier
next to every sign-in. Two tiers can produce that:
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and on by default — which also means every login IP address of
every account on the instance leaves the host.
- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read
from a local directory. The ASN database is the authoritative source
for the carrier and the AS number. Note that the resolver is
online-first: having the databases in place is not by itself enough to
stop the outbound lookup, see "Turning the online lookup off entirely"
below.
for the carrier and the AS number. The resolver reads these first: an
address the local databases can place never leaves the host.
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and the only tier on a host without the databases — which
means every login IP address of every account on that instance leaves
the host. With the databases mounted it sees only the addresses they
could not place, and you can switch it off entirely (below).
The offline tier is optional and absent by default. The published image
only carries the databases when the image build had a MaxMind licence key
@@ -73,8 +73,8 @@ is the subject of this page.
## Turning the online lookup off entirely
Once the offline tier resolves, the third-party lookup is redundant. To
stop all IP egress:
With the databases mounted, the third-party lookup already only sees
addresses they could not place. To remove that remainder too:
```env
IP_GEO_LOOKUP_DISABLED="1"
@@ -127,6 +127,8 @@ the value is in `.env` but not on the `environment:` whitelist — compare
against `docker-compose.yml`.
**The carrier column is empty.** The carrier also comes from the online
provider's ISP field, so it is normally filled even without the ASN
database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no
`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty.
provider's ISP field, but the online lookup only runs for an address the
City database could not place. On a host with `GeoLite2-City.mmdb` and no
`GeoLite2-ASN.mmdb`, a location that resolves locally has no carrier to
go with it — mount the ASN database too. With neither database, the
online provider fills both, unless `IP_GEO_LOOKUP_DISABLED="1"` is set.
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Fehlgeschlagen",
"auditDetails": "Details",
"deleteAllData": "Alle Daten global löschen",
"deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer. Nutzerkonten bleiben bestehen.",
"deleteAllDescription": "Löscht alle Datensätze aller Konten: Messwerte, Medikamente, Laborwerte, Dokumente, Zyklus-Einträge, Stimmung, Workouts, Schlaf, Coach-Unterhaltungen, Integrationen und die Audit-Historie. Die Konten selbst und die Zugangsdaten, mit denen man sich anmeldet, bleiben erhalten.",
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.",
"deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.",
"deleteButton": "Alle Daten löschen",
"deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
"finalDelete": "Endgültig löschen",
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.",
"deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.",
"authRegister": "Registrierung",
"authLogin": "Anmeldung",
"authLoginPasskey": "Passkey-Anmeldung",
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Failed",
"auditDetails": "Details",
"deleteAllData": "Delete all data globally",
"deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.",
"deleteAllDescription": "Deletes every record every account holds: measurements, medications, laboratory results, documents, cycle logs, mood, workouts, sleep, Coach conversations, integrations and the audit history. The accounts themselves and the credentials that sign into them are kept.",
"deleteAllConfirm": "Really delete all data?",
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.",
"deleteAllConfirmDescription": "This permanently deletes every account's records across the whole database, not a selection of tables. Kept: the user accounts and the credentials that sign into them, the instance configuration, and the delegation grants that keep managed profiles reachable. A receipt for this wipe is written to the audit log afterwards; the audit history before it goes with everything else.",
"deleteButton": "Delete all data",
"deleteAllPromptLabel": "Type {token} to continue",
"finalDelete": "Delete permanently",
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.",
"authRegister": "Registration",
"authLogin": "Login",
"authLoginPasskey": "Passkey login",
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Fallido",
"auditDetails": "Detalles",
"deleteAllData": "Eliminar todos los datos globalmente",
"deleteAllDescription": "Elimina todos los datos de mediciones, medicación, integraciones y registros de todos los usuarios. Las cuentas de usuario se conservarán.",
"deleteAllDescription": "Elimina todos los registros de todas las cuentas: mediciones, medicamentos, resultados de laboratorio, documentos, registros de ciclo, estado de ánimo, entrenamientos, sueño, conversaciones del Coach, integraciones y el historial de auditoría. Las cuentas y las credenciales de inicio de sesión se conservan.",
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
"deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.",
"deleteAllConfirmDescription": "Esto elimina permanentemente todos los registros de todas las cuentas en toda la base de datos, no una selección de tablas. Se conservan: las cuentas de usuario y sus credenciales de inicio de sesión, la configuración de la instancia y las concesiones de acceso que mantienen accesibles los perfiles gestionados. Después se escribe un comprobante de este borrado en el registro de auditoría; el historial anterior se va con todo lo demás.",
"deleteButton": "Eliminar todos los datos",
"deleteAllPromptLabel": "Escribe {token} para continuar",
"finalDelete": "Eliminar permanentemente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.",
"authRegister": "Registro",
"authLogin": "Iniciar sesión",
"authLoginPasskey": "Inicio de sesión con passkey",
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Échec",
"auditDetails": "Détails",
"deleteAllData": "Supprimer toutes les données globalement",
"deleteAllDescription": "Supprime toutes les données de mesures, de médicaments, d'intégrations et de journaux pour tous les utilisateurs. Les comptes utilisateurs sont conservés.",
"deleteAllDescription": "Supprime tous les enregistrements de tous les comptes : mesures, médicaments, résultats de laboratoire, documents, journaux de cycle, humeur, entraînements, sommeil, conversations du Coach, intégrations et historique d'audit. Les comptes et les identifiants de connexion sont conservés.",
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
"deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.",
"deleteAllConfirmDescription": "Cette action supprime définitivement tous les enregistrements de tous les comptes dans l'ensemble de la base de données, et non une sélection de tables. Sont conservés : les comptes utilisateurs et leurs identifiants de connexion, la configuration de l'instance et les délégations qui gardent les profils gérés accessibles. Un reçu de cet effacement est ensuite écrit dans le journal d'audit ; l'historique antérieur part avec le reste.",
"deleteButton": "Supprimer toutes les données",
"deleteAllPromptLabel": "Tapez {token} pour continuer",
"finalDelete": "Supprimer définitivement",
"deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.",
"authRegister": "Inscription",
"authLogin": "Connexion",
"authLoginPasskey": "Connexion par passkey",
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Non riuscito",
"auditDetails": "Dettagli",
"deleteAllData": "Elimina tutti i dati globalmente",
"deleteAllDescription": "Elimina tutti i dati di misurazioni, farmaci, integrazioni e log di tutti gli utenti. Gli account utente vengono conservati.",
"deleteAllDescription": "Elimina tutti i record di tutti gli account: misurazioni, farmaci, risultati di laboratorio, documenti, registri del ciclo, umore, allenamenti, sonno, conversazioni del Coach, integrazioni e cronologia di audit. Gli account e le credenziali di accesso vengono conservati.",
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
"deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.",
"deleteAllConfirmDescription": "Questa operazione elimina definitivamente tutti i record di tutti gli account nell'intero database, non una selezione di tabelle. Vengono conservati: gli account utente e le loro credenziali di accesso, la configurazione dell'istanza e le deleghe che mantengono raggiungibili i profili gestiti. Al termine nel log di audit viene scritta una ricevuta di questa cancellazione; la cronologia precedente se ne va con tutto il resto.",
"deleteButton": "Elimina tutti i dati",
"deleteAllPromptLabel": "Digita {token} per continuare",
"finalDelete": "Elimina definitivamente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.",
"authRegister": "Registrazione",
"authLogin": "Accedi",
"authLoginPasskey": "Accesso con passkey",
+3 -3
View File
@@ -6485,13 +6485,13 @@
"outcomeFailed": "Nieudane",
"auditDetails": "Szczegóły",
"deleteAllData": "Usuń wszystkie dane globalnie",
"deleteAllDescription": "Usuwa wszystkie dane pomiarów, leków, integracji i logów wszystkich użytkowników. Konta użytkowników zostaną zachowane.",
"deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont: pomiary, leki, wyniki laboratoryjne, dokumenty, wpisy cyklu, nastrój, treningi, sen, rozmowy z Coachem, integracje i historię audytu. Konta i dane logowania zostają zachowane.",
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
"deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.",
"deleteAllConfirmDescription": "Ta operacja trwale usuwa wszystkie rekordy wszystkich kont w całej bazie danych, a nie wybrane tabele. Zachowane zostają: konta użytkowników i ich dane logowania, konfiguracja instancji oraz uprawnienia, dzięki którym profile zarządzane pozostają dostępne. Po zakończeniu do dziennika audytu trafia potwierdzenie tego usunięcia; wcześniejsza historia znika razem z resztą.",
"deleteButton": "Usuń wszystkie dane",
"deleteAllPromptLabel": "Wpisz {token}, aby kontynuować",
"finalDelete": "Usuń trwale",
"deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.",
"authRegister": "Rejestracja",
"authLogin": "Zaloguj",
"authLoginPasskey": "Logowanie kluczem passkey",
@@ -0,0 +1,170 @@
/**
* 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([]);
});
});
+6 -73
View File
@@ -35,11 +35,12 @@
* that model; add a column to `model User` without classifying it and it goes
* red naming the column.
*/
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 {
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
@@ -51,61 +52,7 @@ import {
wipeWhere,
} from "@/lib/data-wipe/wipe-plan";
const SCHEMA = join(process.cwd(), "prisma", "schema.prisma");
interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
interface ModelShape {
scalars: string[];
relations: Relation[];
}
function parseSchema(schema: string): Map<string, ModelShape> {
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
(m) => ({ name: m[1], body: m[2] }),
);
const modelNames = new Set(blocks.map((b) => b.name));
const out = new Map<string, ModelShape>();
for (const block of blocks) {
const scalars: string[] = [];
const relations: Relation[] = [];
for (const rawLine of block.body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
if (!match) continue;
const [, field, type, isList, isOptional] = match;
if (modelNames.has(type)) {
// A list side of a relation carries no column and cannot cascade.
if (isList) continue;
relations.push({
target: type,
cascade: line.includes("onDelete: Cascade"),
optional: Boolean(isOptional),
});
continue;
}
if (isList && type === type.toUpperCase()) {
// Scalar / enum list column — still a column.
scalars.push(field);
continue;
}
scalars.push(field);
}
out.set(block.name, { scalars, relations });
}
return out;
}
const schemaText = readFileSync(SCHEMA, "utf8");
const models = parseSchema(schemaText);
const models = parseWipeSchema();
const wipeSet = new Set<string>(WIPE_MODELS);
describe("data-wipe completeness", () => {
@@ -158,21 +105,7 @@ describe("data-wipe completeness", () => {
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
// Fixpoint over required cascade relations: a model is covered when the
// database removes it as a consequence of a row the wipe deletes.
const covered = new Set<string>(wipeSet);
for (;;) {
let grew = false;
for (const [name, shape] of models) {
if (covered.has(name)) continue;
const reached = shape.relations.some(
(r) => r.cascade && !r.optional && covered.has(r.target),
);
if (reached) {
covered.add(name);
grew = true;
}
}
if (!grew) break;
}
const covered = reachedByCascade(models, wipeSet);
const orphans = [...models]
.filter(([, shape]) => !shape.scalars.includes("userId"))
+102
View File
@@ -0,0 +1,102 @@
/**
* The shape of `prisma/schema.prisma` the two wipe guards read.
*
* Both of them ask the same two questions — which models carry a `userId`, and
* which models the database removes on its own when a row they do delete goes
* — so the parser answering them lives in one place. Two copies would drift,
* and a guard that answers from its own private idea of the schema is a guard
* that can be green for the wrong reason.
*
* Deliberately regex-shaped rather than a real parser: it runs inside
* `pnpm test` with no database and no Prisma engine, and model blocks plus
* field lines are the most stable syntax in the file. Every consumer asserts a
* floor on how much it found, so a parser that silently stops matching fails
* loudly instead of passing vacuously.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
export interface ModelShape {
scalars: string[];
relations: Relation[];
}
export const WIPE_SCHEMA_PATH = join(process.cwd(), "prisma", "schema.prisma");
export function parseWipeSchema(
schemaPath: string = WIPE_SCHEMA_PATH,
): Map<string, ModelShape> {
const schema = readFileSync(schemaPath, "utf8");
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
(m) => ({ name: m[1], body: m[2] }),
);
const modelNames = new Set(blocks.map((b) => b.name));
const out = new Map<string, ModelShape>();
for (const block of blocks) {
const scalars: string[] = [];
const relations: Relation[] = [];
for (const rawLine of block.body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
if (!match) continue;
const [, field, type, isList, isOptional] = match;
if (modelNames.has(type)) {
// A list side of a relation carries no column and cannot cascade.
if (isList) continue;
relations.push({
target: type,
cascade: line.includes("onDelete: Cascade"),
optional: Boolean(isOptional),
});
continue;
}
if (isList && type === type.toUpperCase()) {
// Scalar / enum list column — still a column.
scalars.push(field);
continue;
}
scalars.push(field);
}
out.set(block.name, { scalars, relations });
}
return out;
}
/**
* Every model the database itself removes once the seed models are deleted.
*
* A fixpoint over REQUIRED cascade relations: an optional relation survives
* its parent, so it proves nothing about the row going away. The result
* includes the seeds.
*/
export function reachedByCascade(
models: Map<string, ModelShape>,
seeds: Iterable<string>,
): Set<string> {
const covered = new Set<string>(seeds);
for (;;) {
let grew = false;
for (const [name, shape] of models) {
if (covered.has(name)) continue;
const reached = shape.relations.some(
(r) => r.cascade && !r.optional && covered.has(r.target),
);
if (reached) {
covered.add(name);
grew = true;
}
}
if (!grew) break;
}
return covered;
}
+63 -50
View File
@@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
import {
ADMIN_WIPE_MODELS,
resolveGlobalWipeDelegate,
USER_RESET,
wipeDelegateKey,
WIPE_TRANSACTION_MAX_WAIT_MS,
WIPE_TRANSACTION_TIMEOUT_MS,
} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Admin-only global wipe of every account's data.
* Keeps users/passkeys so access to the app remains possible.
*
* The set of tables is not written here. It is declared once in
* `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe
* reads — and held against `prisma/schema.prisma` by
* `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling,
* so a model added to the schema cannot quietly land outside this wipe. It had
* done exactly that: the inline list this route used to carry named nine
* tables, written when nine was most of the schema, and stayed at nine while
* the schema grew past a hundred. Laboratory results, documents, cycle logs,
* journals, mood, workouts, sleep and Coach conversations all survived the
* wipe an operator runs before handing the box on.
*
* `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance
* configuration, the delegation grants that keep managed profiles reachable —
* carries its reason in that file.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
@@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
// describing "an admin is about to wipe data" survives a crash midway or a
// rollback. On success the transaction takes this row with the rest of the
// history — an audit trail is a record of who did what from where, which is
// exactly the personal data an operator clearing a box means to remove — and
// the receipt written after the commit replaces it.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
const counts = await prisma.$transaction(
async (tx) => {
const perModel: Record<string, number> = {};
for (const model of ADMIN_WIPE_MODELS) {
const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany(
{},
);
if (count > 0) perModel[wipeDelegateKey(model)] = count;
}
// v1.4.6 left these out, so encrypted Telegram bot tokens
// (NotificationChannel.config) and Web Push endpoints survived a
// wipe. Cascading via NotificationPreference is handled by the
// schema's onDelete: Cascade on the channel FK.
const notificationChannels = await tx.notificationChannel.deleteMany({});
const pushSubscriptions = await tx.pushSubscription.deleteMany({});
const telegramScheduledDeletions =
await tx.telegramScheduledDeletion.deleteMany({});
// Every account row survives; the personal data carried on its own
// columns does not. Same classification contract as the model list.
await tx.user.updateMany({ data: USER_RESET });
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return perModel;
},
{
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
},
);
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
authChallenges: authChallenges.count,
notificationChannels: notificationChannels.count,
pushSubscriptions: pushSubscriptions.count,
telegramScheduledDeletions: telegramScheduledDeletions.count,
};
});
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
// Written after the transaction commits, so the row cannot be removed by the
// AuditLog delete inside it. The instance's whole audit history goes; this
// one row stays as the receipt for the erasure the operator asked for.
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
details: { deletedRows, models: counts },
});
annotate({
meta: {
deleted_rows: deletedRows,
models_wiped: ADMIN_WIPE_MODELS.length,
models_with_rows: Object.keys(counts).length,
},
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
return apiSuccess({ cleared: true, deletedRows, models: counts });
});
+9 -18
View File
@@ -46,16 +46,14 @@ export function DangerZoneSection() {
// Idempotency-Key prevents a double-submit from re-running the
// destructive transaction, matching the Backups restore path.
const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
// The server returns one count per table that actually held rows. It
// used to return nine fixed names, which is how nobody noticed the
// wipe had stopped covering the schema: the result line read the same
// whether it had cleared nine tables or ninety.
return apiDelete<{
measurements: number;
intakeEvents: number;
medications: number;
apiTokens: number;
withingsConnections: number;
authChallenges: number;
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
cleared: boolean;
deletedRows: number;
models: Record<string, number>;
}>(
"/api/admin/data",
{ confirm: WIPE_CONFIRM_TOKEN },
@@ -66,15 +64,8 @@ export function DangerZoneSection() {
queryClient.invalidateQueries();
setWipeMsg(
t("admin.deletedResult", {
measurements: data.measurements,
medications: data.medications,
intakeEvents: data.intakeEvents,
apiTokens: data.apiTokens,
withingsConnections: data.withingsConnections,
authChallenges: data.authChallenges,
notificationChannels: data.notificationChannels,
pushSubscriptions: data.pushSubscriptions,
telegramScheduledDeletions: data.telegramScheduledDeletions,
rows: data.deletedRows,
tables: Object.keys(data.models).length,
}),
);
},
+13 -12
View File
@@ -188,11 +188,12 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => {
});
});
// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the
// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback
// consulted only when the online lookup misses (provider down / non-ok) or
// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => {
// Offline-first resolver: the GeoLite2 City MMDB answers when it is mounted
// and holds the address, and the `ipwho.is` HTTPS lookup only sees what it
// could not place. Between v1.18.10 and v1.37 it ran the other way round —
// these two cases pinned that order, and they are the reason a host with the
// databases mounted still sent every login IP to a third party.
describe("lookupIpLocation — offline-first city resolver", () => {
// Helper: a non-ok online response forces the offline fallback to run.
function onlineMiss(): Response {
return new Response("", {
@@ -201,7 +202,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
});
}
it("prefers the online provider even when the offline DB is present", async () => {
it("prefers the offline DB over the online provider, and sends nothing", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
@@ -221,12 +222,13 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
// Online wins by default; the offline München record is not consulted.
expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE");
expect(fetchSpy).toHaveBeenCalledTimes(1);
// The mounted database answers, so the provider is never asked and the
// address never leaves the host.
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls back to the offline German exonym when the online lookup misses", async () => {
it("uses the German exonym the offline DB carries rather than the provider's fold", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
@@ -237,8 +239,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
// Online was tried first (and missed), then the offline tier resolved.
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("offline fallback uses the English city name when the German exonym is absent", async () => {
+234
View File
@@ -0,0 +1,234 @@
/**
* The offline tier is consulted FIRST, and a local hit makes no request.
*
* From v1.18.10 until v1.37 the resolver queried the online provider first
* and read the MMDBs only when that missed. Every document describing the
* feature said the opposite — the contributor notes, the self-hosting runbook,
* the admin notification that tells an operator their login IPs are leaving
* the server. So an operator who mounted the databases in order to stop that
* egress kept every bit of it, and nothing said so: the location resolved, the
* admin overview filled in, and the only difference was where the address had
* been.
*
* The assertion that matters is therefore about the fetch that does NOT
* happen. Watched red: restore the old order (online lookup first, offline as
* the fallback) and the first two cases fail on a call count of 1.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The MMDB readers are stubbed: `loadMmdbReader` only needs the file to
// exist, and what the reader answers is this table.
const cityRows = new Map<string, unknown>();
const asnRows = new Map<string, unknown>();
vi.mock("mmdb-lib", () => ({
Reader: class {
private readonly rows: Map<string, unknown>;
constructor(buf: Buffer) {
// The two readers are told apart by the stub file's own content, which
// is the only thing distinguishing them at construction time.
this.rows = buf.toString().startsWith("asn") ? asnRows : cityRows;
}
get(ip: string): unknown {
return this.rows.get(ip) ?? null;
}
},
}));
// safeFetch's requirePublicHost path runs through undici's own `fetch`.
// Delegate it to the global stub so an outbound call is visible either way.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findMany: vi.fn(async () => []) } },
}));
const ORIGINAL_ENV = { ...process.env };
let tmpRoot: string;
function jsonOk(value: unknown): Response {
return new Response(
new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer,
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function writeCityDb(): void {
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
}
function writeAsnDb(): void {
writeFileSync(join(tmpRoot, "GeoLite2-ASN.mmdb"), Buffer.from("asn-stub"));
}
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_URL;
delete process.env.IP_GEO_LOOKUP_DISABLED;
delete process.env.IP_GEO_ALLOW_INSECURE;
tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-offline-first-"));
process.env.GEOLITE2_DIR = tmpRoot;
cityRows.clear();
asnRows.clear();
vi.resetModules();
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("geo resolver is offline-first", () => {
it("makes no outbound request when the MMDBs place the address", async () => {
writeCityDb();
writeAsnDb();
cityRows.set("8.8.8.8", {
city: { names: { de: "Nürnberg", en: "Nuremberg" } },
country: { iso_code: "DE" },
});
asnRows.set("8.8.8.8", {
autonomous_system_number: 3320,
autonomous_system_organization: "Deutsche Telekom AG",
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("8.8.8.8")).toEqual({
location: "Nürnberg, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
});
expect(
fetchSpy,
"an address the local databases placed still left the host",
).not.toHaveBeenCalled();
});
it("makes no outbound request when only the City MMDB is mounted", async () => {
// The ASN database is a separate download and an operator may have only
// the City one. Reaching out for a carrier string would send the address
// off the host anyway, which is the thing the mount was for.
writeCityDb();
cityRows.set("9.9.9.9", {
city: { names: { en: "Zurich" } },
country: { iso_code: "CH" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("9.9.9.9")).toEqual({
location: "Zurich, CH",
asn: null,
carrier: null,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls through to the online provider when the MMDB holds no row", async () => {
writeCityDb();
writeAsnDb();
// Databases present, this address simply not in them.
const fetchSpy = vi.fn().mockResolvedValue(
jsonOk({
success: true,
city: "Berlin",
country_code: "DE",
connection: { asn: 3209, isp: "Vodafone" },
}),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("1.1.1.1")).toEqual({
location: "Berlin, DE",
asn: 3209,
carrier: "Vodafone",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("keeps the offline ASN authoritative when the location came from online", async () => {
writeCityDb();
writeAsnDb();
asnRows.set("1.1.1.1", {
autonomous_system_number: 13335,
autonomous_system_organization: "Cloudflare, Inc.",
});
const fetchSpy = vi.fn().mockResolvedValue(
jsonOk({
success: true,
city: "Berlin",
country_code: "DE",
connection: { asn: 3209, isp: "Vodafone" },
}),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("1.1.1.1")).toEqual({
location: "Berlin, DE",
asn: 13335,
carrier: "Cloudflare, Inc.",
});
});
it("leaves the online path unchanged when no MMDB is mounted", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("8.8.8.8")).toEqual({
location: "Berlin, DE",
asn: null,
carrier: null,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
});
it("resolves locally with IP_GEO_LOOKUP_DISABLED set, and never reaches the network without the databases", async () => {
// The kill switch is unchanged by the reordering: it disables step 3
// only, so an offline hit still resolves and an offline miss returns
// null rather than going online.
process.env.IP_GEO_LOOKUP_DISABLED = "1";
writeCityDb();
cityRows.set("8.8.8.8", {
city: { names: { en: "Vienna" } },
country: { iso_code: "AT" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect((await lookupIpGeo("8.8.8.8")).location).toBe("Vienna, AT");
expect((await lookupIpGeo("1.1.1.1")).location).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+86 -14
View File
@@ -434,6 +434,46 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
AccountGrant: ["grantorId", "granteeId"],
};
/**
* Models the ADMIN wipe keeps although the per-account wipe deletes them.
*
* `DELETE /api/admin/data` is the same action pointed at every account at
* once: the operator clearing a box before handing it on. It therefore reads
* {@link WIPE_MODELS} rather than a list of its own — that list was written
* inline once and had drifted to nine tables against a schema of a hundred and
* twenty-two by the time anybody counted.
*
* The two actions differ in exactly one place, and it is written here rather
* than folded into the route so the difference has to be argued for.
*/
export const ADMIN_WIPE_EXEMPT: Readonly<Record<string, string>> = {
AccountGrant:
"a grant is the only way into a managed profile — that record has no credentials of its own, so deleting every grant at once would leave a set of accounts nobody can open, administer or delete. This action preserves accounts by definition, and an access edge without the record behind it is not personal data: everything those profiles hold is wiped with everyone else's.",
};
/**
* Every model the admin wipe deletes, for all accounts, children first.
*
* Derived, so a model added to {@link WIPE_MODELS} is in both wipes at once
* and a model excused from this one has to say why above.
*/
export const ADMIN_WIPE_MODELS: readonly string[] = WIPE_MODELS.filter(
(model) => !(model in ADMIN_WIPE_EXEMPT),
);
/**
* Budget for a wipe transaction.
*
* Prisma's default interactive-transaction timeout is 5 s, which was already
* optimistic for thirteen `deleteMany` statements and is not survivable for
* the full model set on an account with a large import behind it — let alone
* for the admin wipe, which runs the same set across every account. The whole
* action is one transaction on purpose: a half-applied wipe is worse than
* none, because the person is told it succeeded either way.
*/
export const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
export const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
/** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */
export function wipeDelegateKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
@@ -456,14 +496,37 @@ export interface UserScopedDeleteDelegate {
deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>;
}
/** The same slice for the admin wipe, which selects no account at all. */
export interface UnscopedDeleteDelegate {
deleteMany(args: Record<string, never>): Promise<{ count: number }>;
}
interface RawDeleteDelegate {
deleteMany(args: { where?: object }): Promise<{ count: number }>;
}
/**
* The Prisma delegate for a model name, or a thrown error naming the model.
*
* One documented cast: {@link WIPE_MODELS} is a literal list of model names
* checked against `schema.prisma` by the completeness test, and every entry
* indexes a delegate that exposes `deleteMany`. The test also asserts every
* entry resolves, so an unresolvable name fails the build rather than the
* request.
*/
function rawDelegate(client: object, model: string): RawDeleteDelegate {
const delegate = (client as Record<string, RawDeleteDelegate>)[
wipeDelegateKey(model)
];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
return delegate;
}
/**
* Resolve a model name to its delegate on a client or transaction handle.
*
* One documented cast: `WIPE_MODELS` is a literal list of model names checked
* against `schema.prisma` by the completeness test, and every entry indexes a
* delegate that exposes `deleteMany`. The test also asserts every entry
* resolves, so an unresolvable name fails the build rather than the request.
*
* A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the
* caller's `{ userId }` into that model's own ownership predicate. The caller
* still says "this account's rows" and still gets a count back; only the
@@ -474,15 +537,7 @@ export function resolveWipeDelegate(
client: object,
model: string,
): UserScopedDeleteDelegate {
const delegate = (
client as Record<
string,
{ deleteMany(args: { where: object }): Promise<{ count: number }> }
>
)[wipeDelegateKey(model)];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
const delegate = rawDelegate(client, model);
if (!WIPE_OWNER_FIELDS[model]) {
return delegate as UserScopedDeleteDelegate;
}
@@ -491,3 +546,20 @@ export function resolveWipeDelegate(
delegate.deleteMany({ where: wipeWhere(model, where.userId) }),
};
}
/**
* Resolve a model name to a delegate that deletes every row of it.
*
* The admin wipe asks a different question from the per-account one — "every
* row of this model" rather than "this account's rows" — so it gets its own
* resolver instead of a `where` the caller has to remember to leave out.
* {@link WIPE_OWNER_FIELDS} is irrelevant here by construction: with no
* account to select, a row that belongs to two of them is deleted once.
*/
export function resolveGlobalWipeDelegate(
client: object,
model: string,
): UnscopedDeleteDelegate {
const delegate = rawDelegate(client, model);
return { deleteMany: () => delegate.deleteMany({}) };
}
+60 -44
View File
@@ -1,18 +1,24 @@
/**
* IP geolocation lookup for audit-log enrichment.
*
* v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup
* (free, no key) is the DEFAULT path — every self-host resolves a
* location out of the box with no MaxMind licence configured. The
* bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb`
* is an OPTIONAL fallback (perspective: removed later): it is consulted
* only when the online lookup misses (provider down / rate-limited) or
* when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
* OFFLINE-FIRST resolver. The MaxMind GeoLite2 MMDBs at `/opt/geolite2/`
* (or wherever `GEOLITE2_DIR` points) answer whenever they can, and the
* online provider only ever sees an address they could not place. That
* ordering is the whole reason an operator mounts the databases: a lookup
* that resolves locally sends nothing anywhere. The databases stay OPTIONAL
* — a self-host without them resolves every location through the online
* provider, which needs no licence and works out of the box.
*
* 1. Private / loopback / opt-out → null (no lookup).
* 1. Private / loopback → null (no lookup).
* 2. Per-IP cache hit → return immediately.
* 3. Online `ipwho.is` lookup → return on success.
* 4. Offline MMDB present → local read fallback on an online miss.
* 3. Offline MMDB present → local read; on a hit the request ends here.
* 4. Online lookup → only on an offline miss, and only when
* `IP_GEO_LOOKUP_DISABLED` is not set.
*
* It was the other way round from v1.18.10 until v1.37: online first, the
* local databases as the fallback. The documentation described this order the
* whole time, so a host with the MMDBs mounted was sending every login IP to a
* third party while its own runbook said it was not.
*
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpGeo` is the
@@ -121,8 +127,9 @@ const PRIVATE_IP =
// the ip-api.com shape (`status` + `countryCode`), so operators whose egress
// ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json`
// (or any keyed provider matching one of those shapes) with no code change.
// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the
// online lookup misses, and skipped entirely when the databases are absent.
// The offline GeoLite2 MMDB tier stays OPTIONAL, but it goes FIRST when it is
// there: this provider is consulted only for an address the local databases
// could not place, and not at all on a host that has them and hits.
const DEFAULT_GEO_URL = "https://ipwho.is";
// The host portion of DEFAULT_GEO_URL — the provider name the admin status
@@ -261,10 +268,10 @@ export function __resetGeoLite2CacheForTests(): void {
//
// The CI workflow drops an `.empty` marker into the geo asset directory
// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in
// which case the City + ASN MMDBs are absent and every lookup hits the
// online fallback. `offlineGeoReady()` is the canonical truth used by
// `/api/version` and the admin status surface so the two paths cannot
// disagree.
// which case the City + ASN MMDBs are absent and every lookup goes to the
// online provider. `offlineGeoReady()` is the canonical truth used by
// `/api/version`, the admin status surface, and the resolver's own
// offline-first branch, so the three cannot disagree.
//
// `notifiedThisProcess` is a module-level latch so the admin only
// receives the "offline geo is disabled" notification once per worker
@@ -313,7 +320,7 @@ async function notifyOfflineGeoUnavailable(): Promise<void> {
return;
}
getEvent()?.addWarning(
"geo: offline databases unavailable, falling back to ipwho.is — notifying admins",
`geo: offline databases unavailable, every lookup goes online to ${resolveGeoProviderHost()} — notifying admins`,
);
for (const admin of admins) {
await dispatchLocalisedNotification({
@@ -507,15 +514,24 @@ async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
/**
* Resolve location + carrier + AS number for an IP in a single pass.
*
* v1.25.8 — the online provider lookup now also yields the carrier, so a
* self-host without the optional offline GeoLite2-ASN MMDB still surfaces a
* network operator in the admin sign-in overview. The merge order:
* OFFLINE FIRST. The order below is the one the documentation has always
* described and the code did not do: for two releases the online provider was
* queried first and the local databases were the fallback, so an operator who
* had mounted the MMDBs precisely so that no address would leave the host was
* sending every login IP to a third party anyway. The MMDBs answer; the online
* provider only sees an address the local databases could not place.
*
* 1. Online lookup (one request) → location + carrier + ASN.
* 2. Offline City MMDB → location fallback on an online miss (if present).
* 3. Offline ASN MMDB → AUTHORITATIVE carrier/ASN when present (it carries
* the canonical org name the short-label folder expects); the online
* carrier is the fallback so a host without the DBs still resolves one.
* 1. Offline ASN MMDB → carrier + AS number when configured (authoritative:
* it carries the canonical org name the short-label folder expects).
* 2. Offline City MMDB → location. Present and resolving means the request
* ends here, with no egress at all.
* 3. Online lookup → only on an offline miss (databases absent, unreadable,
* or holding no row for this address). Fills the location, and the
* carrier/ASN when the offline tier had none, so a host without the
* databases still surfaces an operator in the admin sign-in overview.
*
* `IP_GEO_LOOKUP_DISABLED=1` remains the full off-switch for step 3 — with it
* set, an offline miss resolves to null rather than reaching the network.
*
* Never throws; the auth-audit caller is fire-and-forget. The resolved
* record (including all-null misses) is cached per IP for a bounded TTL.
@@ -527,28 +543,28 @@ export async function lookupIpGeo(ip: string | null): Promise<GeoResolved> {
const cached = getCachedGeo(ip);
if (cached) return cached;
// v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
// is the primary resolver for every self-host: it needs no MaxMind licence
// and resolves out of the box. The bundled GeoLite2 offline tier is an
// OPTIONAL fallback, so a missing offline tier is the expected baseline.
const online = await lookupIpOnline(ip);
let location = online?.location ?? null;
let asn = online?.asn ?? null;
let carrier = online?.carrier ?? null;
// Offline City MMDB fills the location when the online tier missed it.
if (!location && offlineGeoReady()) {
location = lookupIpLocationOffline(ip);
}
// Offline ASN MMDB is authoritative for the carrier/ASN when configured;
// prefer it over the online ISP string but keep the online value as the
// fallback. `lookupIpAsn` also fires the one-shot "no resolver" admin alert
// when neither an offline ASN reader nor the offline tier is present.
// Offline ASN MMDB first, and unconditionally: it is a local file read, and
// it is also what fires the one-shot "no offline resolver" admin alert when
// neither an ASN reader nor the offline tier is present.
let asn: number | null = null;
let carrier: string | null = null;
const offlineAsn = lookupIpAsn(ip);
if (offlineAsn) {
asn = offlineAsn.asn;
if (offlineAsn.carrier) carrier = offlineAsn.carrier;
carrier = offlineAsn.carrier;
}
let location = offlineGeoReady() ? lookupIpLocationOffline(ip) : null;
// The address the local databases could not place is the only one that
// travels. A host with the MMDBs mounted and a hit makes no request at all.
if (!location) {
const online = await lookupIpOnline(ip);
if (online) {
location = online.location;
if (asn === null) asn = online.asn;
if (!carrier) carrier = online.carrier;
}
}
const resolved: GeoResolved = { location, asn, carrier };
+244 -64
View File
@@ -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);
});
});
+3 -247
View File
@@ -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,
+265
View File
@@ -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);
}