mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.
ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:
- Account and managed-profile deletion lock the account (migration 0488:
deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
Every session, refresh token, API token, connector, share link and
step-up elevation is revoked in the same transaction, and the record,
its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
erasure date, after the credential was proved and before a second
factor is asked for. The session and Bearer resolvers and the acting-
account resolver refuse a pending account too, so a credential minted
in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
ended, exactly as the immediate deletion did, with the off-host purge
request committed alongside. The Guardian invariant is decided again;
a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
the writer the weekly copy uses, keeps it out of the wipe, and holds
its off-host purge back with a new not_before on the request. The
ordinary restore brings the record back; restoring that copy withdraws
the held purge. The purge job deletes the copy when its window ends.
Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.
The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
/**
|
|
* Account deletion and the record wipe over Bearer, for an account with a
|
|
* second factor — against a real Postgres, the real mint, and the real gate.
|
|
*
|
|
* App Store guideline 5.1.1(v) requires that an account created in the app can
|
|
* be deleted from the app. Until now an MFA-enrolled account could not: both
|
|
* erasure routes demanded a cookie-side fresh factor and a Bearer transport had
|
|
* no way to present one. The cases below pin that a native client CAN now
|
|
* clear the gate, and only by re-proving a second factor through
|
|
* `POST /api/auth/step-up` — a token alone, a password proof, another token's
|
|
* elevation, and a narrow-scope token all still fail closed.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import * as OTPAuth from "otpauth";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-erasure-step-up-32-bytes-minimum-1234567890";
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
const { hashPassword } = await import("@/lib/auth/password");
|
|
|
|
const USER_ID = "user-erasure-step-up";
|
|
const PASSWORD = "correct horse battery staple 42";
|
|
|
|
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),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "erasure",
|
|
email: "erasure@example.test",
|
|
timezone: "UTC",
|
|
passwordHash: await hashPassword(PASSWORD),
|
|
},
|
|
});
|
|
});
|
|
|
|
async function mintToken(
|
|
label: string,
|
|
permissions: string[] = ["*"],
|
|
): Promise<{ raw: string; id: string }> {
|
|
const raw = `hlk_${label}${"0".repeat(64 - label.length)}`;
|
|
const row = await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: label,
|
|
tokenHash: hashToken(raw),
|
|
permissions,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return { raw, id: row.id };
|
|
}
|
|
|
|
function useToken(raw: string): void {
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
}
|
|
|
|
function useElevation(raw: string | null): void {
|
|
if (raw === null) headerJar.delete("x-step-up");
|
|
else headerJar.set("x-step-up", raw);
|
|
}
|
|
|
|
function req(path: string, method: string, body?: unknown): NextRequest {
|
|
const headers: Record<string, string> = {};
|
|
const auth = headerJar.get("authorization");
|
|
if (auth) headers.authorization = auth;
|
|
const init: RequestInit = { method, headers };
|
|
if (body !== undefined) {
|
|
headers["content-type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
return new NextRequest(`https://health.example${path}`, init as never);
|
|
}
|
|
|
|
async function enrolTotp(): Promise<string> {
|
|
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
const secret = generateTotpSecret();
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: {
|
|
totpSecretEncrypted: encrypt(secret),
|
|
totpConfirmedAt: new Date(),
|
|
totpLastStep: null,
|
|
},
|
|
});
|
|
return secret;
|
|
}
|
|
|
|
function currentTotpCode(secretBase32: string): string {
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: "HealthLog",
|
|
label: "HealthLog",
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
});
|
|
return totp.generate({ timestamp: Date.now() });
|
|
}
|
|
|
|
async function mint(body: Record<string, unknown>): Promise<string> {
|
|
const { POST } = await import("@/app/api/auth/step-up/route");
|
|
const res = await POST(req("/api/auth/step-up", "POST", body));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { data: { elevation: string } };
|
|
return json.data.elevation;
|
|
}
|
|
|
|
async function deleteAccount(): Promise<Response> {
|
|
const { DELETE } = await import("@/app/api/settings/account/route");
|
|
return DELETE(
|
|
req("/api/settings/account", "DELETE", { confirm: "DELETE_ACCOUNT" }),
|
|
);
|
|
}
|
|
|
|
async function wipeData(): Promise<Response> {
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
return DELETE(req("/api/settings/data", "DELETE", { confirm: "DELETE" }));
|
|
}
|
|
|
|
async function errorCodeOf(res: Response): Promise<string | undefined> {
|
|
const json = (await res.json()) as { meta?: { errorCode?: string } };
|
|
return json.meta?.errorCode;
|
|
}
|
|
|
|
async function userExists(): Promise<boolean> {
|
|
return (
|
|
(await getPrismaClient().user.findUnique({ where: { id: USER_ID } })) !==
|
|
null
|
|
);
|
|
}
|
|
|
|
async function auditReasons(action: string): Promise<string[]> {
|
|
const rows = await getPrismaClient().auditLog.findMany({
|
|
where: { action },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
return rows.map(
|
|
(r) => (JSON.parse(r.details ?? "{}") as { reason?: string }).reason ?? "",
|
|
);
|
|
}
|
|
|
|
async function expectAuditReason(
|
|
action: string,
|
|
reason: string,
|
|
timeoutMs = 5_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let seen: string[] = [];
|
|
while (Date.now() < deadline) {
|
|
seen = await auditReasons(action);
|
|
if (seen.includes(reason)) return;
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
expect(seen).toContain(reason);
|
|
}
|
|
|
|
describe("DELETE /api/settings/account over Bearer with a second factor", () => {
|
|
it("refuses a token alone with auth.stepup.required and deletes nothing", async () => {
|
|
await enrolTotp();
|
|
useToken((await mintToken("acc-none")).raw);
|
|
useElevation(null);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
|
|
expect(await userExists()).toBe(true);
|
|
});
|
|
|
|
it("refuses a password-proved elevation and keeps it unspent", async () => {
|
|
await enrolTotp();
|
|
useToken((await mintToken("acc-pw")).raw);
|
|
const elevation = await mint({ method: "password", password: PASSWORD });
|
|
useElevation(elevation);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
|
|
expect(await userExists()).toBe(true);
|
|
await expectAuditReason(
|
|
"auth.stepup.elevation.rejected",
|
|
"insufficient_factor",
|
|
);
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses a narrow-scope token before any elevation is looked at", async () => {
|
|
await enrolTotp();
|
|
useToken((await mintToken("acc-narrow", ["fhir:read"])).raw);
|
|
useElevation(`hle_${"f".repeat(64)}`);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(await userExists()).toBe(true);
|
|
expect(await auditReasons("auth.stepup.elevation.rejected")).toEqual([]);
|
|
});
|
|
|
|
it("refuses an elevation minted by another token of the same account", async () => {
|
|
const secret = await enrolTotp();
|
|
const minter = await mintToken("acc-minter");
|
|
const redeemer = await mintToken("acc-redeemer");
|
|
useToken(minter.raw);
|
|
const elevation = await mint({
|
|
method: "totp",
|
|
code: currentTotpCode(secret),
|
|
});
|
|
useToken(redeemer.raw);
|
|
useElevation(elevation);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await userExists()).toBe(true);
|
|
await expectAuditReason("auth.stepup.elevation.rejected", "wrong_token");
|
|
});
|
|
|
|
it("deletes the account end to end on a TOTP-proved elevation", async () => {
|
|
const secret = await enrolTotp();
|
|
const token = await mintToken("acc-totp");
|
|
useToken(token.raw);
|
|
const elevation = await mint({
|
|
method: "totp",
|
|
code: currentTotpCode(secret),
|
|
});
|
|
useElevation(elevation);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(200);
|
|
// No grace window in this suite: erased now, nothing scheduled.
|
|
expect(await res.json()).toEqual({
|
|
data: { deleted: true, scheduledPurgeAt: null },
|
|
error: null,
|
|
});
|
|
expect(await userExists()).toBe(false);
|
|
// The token and its elevation went with the account.
|
|
expect(await getPrismaClient().apiToken.count()).toBe(0);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
|
|
it("still deletes a single-factor account on a bare wildcard token", async () => {
|
|
useToken((await mintToken("acc-plain")).raw);
|
|
useElevation(null);
|
|
|
|
const res = await deleteAccount();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(await userExists()).toBe(false);
|
|
});
|
|
|
|
it("does not spend the elevation on a refused confirmation", async () => {
|
|
const secret = await enrolTotp();
|
|
useToken((await mintToken("acc-422")).raw);
|
|
const elevation = await mint({
|
|
method: "totp",
|
|
code: currentTotpCode(secret),
|
|
});
|
|
useElevation(elevation);
|
|
|
|
const { DELETE } = await import("@/app/api/settings/account/route");
|
|
const res = await DELETE(
|
|
req("/api/settings/account", "DELETE", { confirm: "nope" }),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await userExists()).toBe(true);
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/settings/data over Bearer with a second factor", () => {
|
|
it("refuses a token alone with auth.stepup.required", async () => {
|
|
await enrolTotp();
|
|
useToken((await mintToken("data-none")).raw);
|
|
useElevation(null);
|
|
|
|
const res = await wipeData();
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
|
|
});
|
|
|
|
it("wipes on a TOTP-proved elevation and spends it exactly once", async () => {
|
|
const secret = await enrolTotp();
|
|
useToken((await mintToken("data-totp")).raw);
|
|
await getPrismaClient().measurement.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
const elevation = await mint({
|
|
method: "totp",
|
|
code: currentTotpCode(secret),
|
|
});
|
|
useElevation(elevation);
|
|
|
|
const first = await wipeData();
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(
|
|
await getPrismaClient().measurement.count({ where: { userId: USER_ID } }),
|
|
).toBe(0);
|
|
// The account and its second factor survive a record wipe.
|
|
const user = await getPrismaClient().user.findUnique({
|
|
where: { id: USER_ID },
|
|
select: { totpConfirmedAt: true },
|
|
});
|
|
expect(user?.totpConfirmedAt).not.toBeNull();
|
|
|
|
// The wipe's own model list carries `ApiToken` and `StepUpElevation`, so
|
|
// the caller's credential goes with the record it erased. That is
|
|
// pre-existing behaviour rather than anything this gate introduced — a
|
|
// single-factor Bearer caller has always lost its token here — and it is
|
|
// asserted rather than assumed, because a native client has to sign in
|
|
// again afterwards.
|
|
expect(
|
|
await getPrismaClient().apiToken.count({ where: { userId: USER_ID } }),
|
|
).toBe(0);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
|
|
// Which means a replay of the very same request is refused outright.
|
|
const second = await wipeData();
|
|
expect(second.status).toBe(401);
|
|
});
|
|
});
|