Files
HealthLog/src/__tests__/managed-profile-contract.test.ts
T
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00

172 lines
6.4 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
const root = process.cwd();
/** Every `route.ts` under the API tree. */
async function apiRouteFiles(): Promise<string[]> {
const entries = await readdir(path.join(root, "src/app/api"), {
recursive: true,
});
return entries
.filter((entry) => entry.endsWith("route.ts"))
.map((entry) => path.join("src/app/api", entry));
}
/** The published path a route file serves, or null when it is unpublished. */
function publishedPathFor(file: string): string | null {
const apiPath = file
.replace(/^src\/app/, "")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
return apiPath in openApiPaths ? apiPath : null;
}
describe("managed-profile contract", () => {
it("publishes the last-Guardian refusal on every published route that emits it", async () => {
// The failure this catches is the one the release audit found: the floor
// is enforced on four surfaces and was DESCRIBED on the one route that
// cannot reach it, while two routes that answer it every time said only
// "that access had already ended". Emitters are discovered from the route
// sources, so a fifth one cannot ship undescribed.
const files = await apiRouteFiles();
expect(files.length).toBeGreaterThan(100);
const emitters: string[] = [];
for (const file of files) {
const source = await readFile(path.join(root, file), "utf8");
if (!source.includes("LastManagedGuardianError")) continue;
if (!source.includes("managed_profile.guardian.required")) continue;
emitters.push(file);
}
// Non-zero, and known: the two grant routes. The lifecycle surfaces that
// also raise it (account deletion, the data wipe) answer through their own
// envelopes and are not in this spec.
expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts");
expect(emitters).toContain(
"src/app/api/account/grants/[id]/renounce/route.ts",
);
const silent = emitters.filter((file) => {
const apiPath = publishedPathFor(file);
if (apiPath === null) return false;
return !JSON.stringify(openApiPaths[apiPath]).includes(
"managed_profile.guardian.required",
);
});
expect(
silent,
"published routes that emit the last-Guardian refusal without publishing it",
).toEqual([]);
});
it("requires a cookie-backed fresh MFA proof for creation", async () => {
const source = await readFile(
path.join(root, "src/app/api/managed-profiles/route.ts"),
"utf8",
);
expect(source).toContain("requireFreshMfa");
expect(source).not.toContain("requireFreshMfaIfEnrolled");
});
it("requires a cookie-backed fresh MFA proof for profile deletion", async () => {
const source = await readFile(
path.join(root, "src/app/api/managed-profiles/[id]/route.ts"),
"utf8",
);
expect(source).toContain("requireFreshMfa");
expect(source).not.toContain("requireFreshMfaIfEnrolled");
});
it("publishes no Guardian refusal a request cannot produce", async () => {
// `managed_profile.guardian.self` was written, shipped and never
// reachable: `self_grant` needs the grantor to equal the grantee, and the
// grantor of a Guardian grant is the PROFILE. Inviting yourself lands on
// the duplicate refusal; naming the profile is refused a step earlier as
// `managed_grantee`. Both doors are asserted in
// `tests/integration/managed-profile-surface.test.ts`, so the arm was
// known to be dead rather than assumed to be, and it was removed rather
// than frozen into the published contract.
const route = await readFile(
path.join(root, "src/app/api/managed-profiles/[id]/guardians/route.ts"),
"utf8",
);
const spec = await readFile(
path.join(root, "docs/api/openapi.yaml"),
"utf8",
);
// Non-zero control: the codes that ARE reachable are still named, so this
// cannot pass by matching an empty file or a renamed family.
expect(route).toContain("managed_profile.guardian.duplicate");
expect(route).toContain("managed_profile.guardian.managed_invitee");
expect(route).not.toContain('errorCode: "managed_profile.guardian.self"');
expect(spec).not.toContain("managed_profile.guardian.self");
});
it("does not publish an emancipation API surface", async () => {
const source = await readFile(
path.join(root, "prisma/schema.prisma"),
"utf8",
);
expect(source.toLowerCase()).not.toContain("emancipation");
const routes = await readdir(
path.join(root, "src/app/api/managed-profiles"),
{ recursive: true },
);
expect(
routes.some((entry) => entry.toLowerCase().includes("emancip")),
).toBe(false);
});
it("does not claim notification fanout before its consent plan exists", async () => {
const source = await readFile(
path.join(root, "prisma/schema.prisma"),
"utf8",
);
expect(source).not.toContain("notification dispatcher fans reminders");
expect(source).toContain("later notification plan");
});
it("uses the same record lock for every Guardian-reducing transition", async () => {
const lifecycle = await readFile(
path.join(root, "src/lib/managed-profiles/lifecycle.ts"),
"utf8",
);
const grants = await readFile(
path.join(root, "src/lib/sharing/grants.ts"),
"utf8",
);
// Both account deletions (the account's own and an admin's) take the one
// request path, and that path decides the Guardian invariant.
const deletionRequest = await readFile(
path.join(root, "src/lib/account-deletion/request.ts"),
"utf8",
);
const deletionRoutes = await Promise.all(
[
"src/app/api/settings/account/route.ts",
"src/app/api/admin/users/[id]/route.ts",
].map((rel) => readFile(path.join(root, rel), "utf8")),
);
expect(lifecycle).toContain("pg_advisory_xact_lock");
expect(lifecycle).toContain("clearManagedProfileMarker");
expect(lifecycle).toContain("deleteManagedProfile");
expect(grants).toContain("reduceManagedProfileGuardian");
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
for (const route of deletionRoutes) {
expect(route).toContain("requestAccountDeletion(");
expect(route).not.toContain("lockAccountForDeletion(");
}
});
});