Files
HealthLog/tests/integration/settings-data-wipe.test.ts
T
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00

509 lines
17 KiB
TypeScript

/**
* Integration guard for `DELETE /api/settings/data` — "Delete All Data".
*
* The whole defect is about rows that survive, so a mocked Prisma proves
* nothing: it can only show that the calls the route makes were made. This
* runs against real Postgres, seeds a row into every table in the schema, runs
* the wipe, and then asks the database what is left.
*
* The seeding is driven by `information_schema` and `pg_constraint` rather
* than a hand-written fixture, for the same reason the wipe list is derived
* rather than hand-written: a fixture written today covers the schema of
* today. A table added next year is seeded automatically and therefore
* asserted automatically. It lives in `./wipe-seeder` because the admin wipe
* has to be measured with the same ruler.
*
* What it asserts:
* - every model in `WIPE_MODELS` has zero rows for the account afterwards;
* - every model in `WIPE_EXEMPT` still has its row, so the "kept" half of
* the confirmation copy is true too;
* - every table the wipe reaches only through a database cascade is empty;
* - the instance-scoped tables are untouched;
* - the `User` row survives with `USER_RESET` applied and
* `USER_KEPT_FIELDS` intact.
*
* Its honest limit: it proves the wipe removes what the plan declares. The
* plan being right is what `src/__tests__/data-wipe-completeness.test.ts`
* holds against `schema.prisma`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder";
import {
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
USER_RESET,
WIPE_EXEMPT,
WIPE_MODELS,
} from "@/lib/data-wipe/wipe-plan";
import type { PrismaClient } from "@/generated/prisma/client";
import {
claimManagedGuardianEgressAuthorization,
resolveNotificationDeliveryIdentity,
} from "@/lib/notifications/delivery-identity";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function managedProfileLockIsHeld(
prisma: PrismaClient,
profileId: string,
): Promise<boolean> {
const rows = await prisma.$queryRaw<Array<{ locked: boolean }>>`
SELECT NOT pg_try_advisory_xact_lock(
hashtextextended(${`managed-profile:${profileId}`}, 0)
) AS locked
`;
return rows[0]?.locked === true;
}
describe("DELETE /api/settings/data leaves nothing it promised to delete", () => {
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
it("wipes every in-scope table and keeps exactly the declared set", async () => {
const prisma = getPrismaClient();
const tables = modelTableMap();
const user = await prisma.user.create({
data: {
username: "wipe-subject",
email: "wipe-subject@example.test",
heightCm: 180,
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
gender: "OTHER",
fullName: "Seeded Name",
insurerName: "Seeded Insurer",
stravaAthleteId: "12345",
// Off, so the reset back to the default (on) is visible below.
environmentAirQualityEnabled: false,
thresholdsJson: { sys: 130 },
onboardingCompletedAt: new Date(),
passwordHash: "argon2-placeholder",
locale: "en",
unitPreference: "metric",
},
});
const seeded = await seedEveryTable(prisma, user.id);
expect(
seeded.length,
"the seeder should reach essentially the whole schema",
).toBeGreaterThan(100);
// The session has to be created after the generic seed so its id is the
// one the cookie carries. `mfaVerifiedAt` is set because the seeder also
// planted a WebAuthn MFA credential, which makes the account MFA-enrolled
// and puts the route behind a fresh step-up.
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
// ── act ──
const { DELETE } = await import("@/app/api/settings/data/route");
const response = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
expect(response.status).toBe(200);
// ── assert: nothing the plan claims to delete is left ──
const survivors: string[] = [];
for (const model of WIPE_MODELS) {
const table = tables.get(model)!;
// `AuditLog` is wiped inside the transaction and then receives exactly
// one new row after it commits: the receipt for the wipe itself. The
// history is gone; the record that the person asked for it is not.
const expected = model === "AuditLog" ? 1 : 0;
if ((await countRows(prisma, table, user.id, model)) > expected) {
survivors.push(`${model} (${table})`);
}
}
expect(
survivors,
`rows survived a wipe that told the person everything was deleted:\n${survivors.join("\n")}`,
).toEqual([]);
// The one surviving audit row is the receipt, not leftover history.
const auditRows = await prisma.auditLog.findMany({
where: { userId: user.id },
select: { action: true },
});
expect(auditRows.map((r) => r.action)).toEqual(["user.data.clear"]);
// ── assert: the kept set is exactly what the copy promises ──
const missing: string[] = [];
for (const model of Object.keys(WIPE_EXEMPT)) {
const table = tables.get(model)!;
if ((await countRows(prisma, table, user.id)) === 0) {
missing.push(`${model} (${table})`);
}
}
expect(
missing,
`the copy says these are kept, and they are gone:\n${missing.join("\n")}`,
).toEqual([]);
// ── assert: the instance's own tables are untouched ──
for (const model of Object.keys(INSTANCE_SCOPED)) {
if (model === "User") continue;
const table = tables.get(model)!;
expect(
await countRows(prisma, table),
`${model} belongs to the instance and must survive one account's wipe`,
).toBeGreaterThan(0);
}
// ── assert: everything reached only by cascade is gone too ──
const wipedTables = new Set(WIPE_MODELS.map((m) => tables.get(m)!));
const exemptTables = new Set(
Object.keys(WIPE_EXEMPT).map((m) => tables.get(m)!),
);
const instanceTables = new Set(
Object.keys(INSTANCE_SCOPED).map((m) => tables.get(m)!),
);
const leftovers: string[] = [];
for (const table of seeded) {
if (wipedTables.has(table)) continue;
if (exemptTables.has(table)) continue;
if (instanceTables.has(table)) continue;
if ((await countRows(prisma, table)) > 0) leftovers.push(table);
}
expect(
leftovers,
`tables the wipe reaches only through a database cascade still hold rows:\n${leftovers.join("\n")}`,
).toEqual([]);
// ── assert: the account survives, reset but signed-in-able ──
const after = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
});
const record = after as unknown as Record<string, unknown>;
expect(record.username).toBe("wipe-subject");
expect(record.passwordHash).toBe("argon2-placeholder");
for (const column of Object.keys(USER_KEPT_FIELDS)) {
expect(record).toHaveProperty(column);
}
expect(record.locale).toBe("en");
expect(record.unitPreference).toBe("metric");
const stillSet: string[] = [];
for (const column of Object.keys(USER_RESET)) {
const value = record[column];
const cleared =
value === null ||
value === false ||
value === 0 ||
(Array.isArray(value) && value.length === 0) ||
value === "aggregated" ||
value === "disconnected" ||
// A column whose reset is a non-empty default (v1.42: the air-quality
// switch resets to on) is cleared when it holds exactly that default.
(typeof value === "boolean" &&
(USER_RESET as Record<string, unknown>)[column] === value);
if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
}
expect(
stillSet,
`User columns the plan resets still carry a value:\n${stillSet.join("\n")}`,
).toEqual([]);
});
it("refuses a wipe that would remove a managed profile's last Guardian", async () => {
const prisma = getPrismaClient();
const [guardian, profile] = await Promise.all([
prisma.user.create({
data: {
username: "wipe-last-guardian",
email: "wipe-last-guardian@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-last-profile",
email: "wipe-last-profile@example.test",
managedProfileAt: new Date(),
},
}),
]);
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
});
await prisma.measurement.create({
data: {
userId: guardian.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const session = await prisma.session.create({
data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const { DELETE } = await import("@/app/api/settings/data/route");
const response = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
expect(response.status).toBe(409);
expect(
await prisma.accountGrant.count({
where: { grantorId: profile.id, granteeId: guardian.id },
}),
).toBe(1);
expect(
await prisma.measurement.count({ where: { userId: guardian.id } }),
).toBe(1);
});
it("makes a queued Guardian claim observe a completed data wipe", async () => {
const prisma = getPrismaClient();
const [guardian, reserveGuardian, profile] = await Promise.all([
prisma.user.create({
data: {
username: "wipe-race-guardian",
email: "wipe-race-guardian@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-race-reserve",
email: "wipe-race-reserve@example.test",
},
}),
prisma.user.create({
data: {
username: "wipe-race-profile",
email: "wipe-race-profile@example.test",
managedProfileAt: new Date(),
},
}),
]);
await Promise.all([
prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
}),
prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: reserveGuardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
}),
]);
expect(
await prisma.accountGrant.count({
where: {
grantorId: profile.id,
access: "MANAGE",
acceptedAt: { not: null },
revokedAt: null,
},
}),
).toBe(2);
const measurement = await prisma.measurement.create({
data: {
userId: guardian.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const session = await prisma.session.create({
data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const delivery = await resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: profile.id,
recordUserId: profile.id,
recipientUserId: guardian.id,
title: "record reminder",
message: "record message",
});
expect(delivery).toMatchObject({ managed: true });
let releaseMeasurement!: () => void;
let markMeasurementLocked!: () => void;
const measurementLocked = new Promise<void>((resolve) => {
markMeasurementLocked = resolve;
});
const holdMeasurement = new Promise<void>((resolve) => {
releaseMeasurement = resolve;
});
const blocker = prisma.$transaction(async (tx) => {
await tx.measurement.update({
where: { id: measurement.id },
data: { value: 81 },
});
markMeasurementLocked();
await holdMeasurement;
});
await measurementLocked;
const { DELETE } = await import("@/app/api/settings/data/route");
const wipe = DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
await vi.waitFor(async () => {
expect(await managedProfileLockIsHeld(prisma, profile.id)).toBe(true);
});
const claim = claimManagedGuardianEgressAuthorization(
delivery!,
"NTFY",
"MEDICATION_REMINDER",
);
await expectPromiseToRemainPending(claim);
releaseMeasurement();
await blocker;
expect((await wipe).status).toBe(200);
await expect(claim).resolves.toBeNull();
expect(await prisma.notificationEgressAuthorization.count()).toBe(0);
expect(await prisma.pushAttempt.count()).toBe(0);
});
it("refuses without the typed confirmation and leaves the record alone", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "wipe-refused", email: "wipe-refused@example.test" },
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
const { DELETE } = await import("@/app/api/settings/data/route");
const response = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "nope" }),
}) as never,
);
expect(response.status).toBe(422);
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
1,
);
});
it("revokes a clinician share link so the token resolves to nothing", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "wipe-sharer", email: "wipe-sharer@example.test" },
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const { hashToken } = await import("@/lib/auth/hmac");
const rawToken = `hls_${"a".repeat(48)}`;
await prisma.clinicianShareLink.create({
data: {
userId: user.id,
tokenHash: hashToken(rawToken),
label: "Practice",
rangeStart: new Date("2026-01-01T00:00:00Z"),
sectionsJson: {},
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
const { resolveShareToken } =
await import("@/lib/clinician-share/resolve-share-token");
expect(await resolveShareToken(rawToken)).not.toBeNull();
const { DELETE } = await import("@/app/api/settings/data/route");
const response = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
expect(response.status).toBe(200);
// A clinician holding the link now reads nothing, not a stale record.
expect(await resolveShareToken(rawToken)).toBeNull();
});
});
async function expectPromiseToRemainPending(promise: Promise<unknown>) {
const settled = await Promise.race([
promise.then(
() => true,
() => true,
),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25)),
]);
expect(settled).toBe(false);
}