Files
HealthLog/tests/integration/settings-account-delete.test.ts
T
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +02:00

221 lines
7.2 KiB
TypeScript

/**
* v1.4.43 QoL (M3) — integration coverage for
* `DELETE /api/settings/account`.
*
* The audit found the danger-zone UI offered only a half-delete (wipe
* data, keep user row + passkeys + audit log + sessions). The route
* was already in tree; this file pins the contract end-to-end against
* real Postgres:
*
* - happy path cascades User + passkeys + audit log + sessions
* - concurrent sibling sessions are invalidated before the row goes
* - the post-delete cookie can no longer authenticate a read
* - 422 when the confirmation token is missing or wrong
* - 401 when called unauthenticated
* - last-admin guard prevents accidental self-foot-shot
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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();
});
async function seedUserWithSession(opts?: {
role?: "ADMIN" | "USER";
username?: string;
}): Promise<{
userId: string;
sessionId: string;
}> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: opts?.username ?? "delete-me",
email: `${opts?.username ?? "delete-me"}@example.test`,
role: opts?.role ?? "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id, sessionId: session.id };
}
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/settings/account", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("DELETE /api/settings/account (real Postgres)", () => {
it("cascades User + passkeys + audit log + sessions", async () => {
const { userId, sessionId } = await seedUserWithSession();
const prisma = getPrismaClient();
// Seed a sibling session, a passkey, and an audit row so we can
// verify every dependency vanishes with the row.
const siblingSession = await prisma.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const passkey = await prisma.passkey.create({
data: {
userId,
name: "iPhone",
credentialId: "cred-1",
credentialPublicKey: Buffer.from("public-key"),
counter: BigInt(0),
credentialDeviceType: "singleDevice",
transports: ["internal"],
},
});
// Seed an unrelated audit row to confirm it gets wiped too.
await prisma.auditLog.create({
data: {
userId,
action: "test.audit.row",
ipAddress: "127.0.0.1",
},
});
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" }));
expect(res.status).toBe(200);
// The user row is gone.
const stillThere = await prisma.user.findUnique({ where: { id: userId } });
expect(stillThere).toBeNull();
// Sessions cascaded.
const sessionsAfter = await prisma.session.findMany({ where: { userId } });
expect(sessionsAfter).toHaveLength(0);
const checkOriginal = await prisma.session.findUnique({
where: { id: sessionId },
});
expect(checkOriginal).toBeNull();
const checkSibling = await prisma.session.findUnique({
where: { id: siblingSession.id },
});
expect(checkSibling).toBeNull();
// Passkey cascaded.
const passkeyAfter = await prisma.passkey.findUnique({
where: { id: passkey.id },
});
expect(passkeyAfter).toBeNull();
// Audit-log rows for that user were purged (route deletes them
// explicitly inside the same logical op for GDPR completeness).
const auditRowsAfter = await prisma.auditLog.findMany({
where: { userId },
});
expect(auditRowsAfter).toHaveLength(0);
});
it("invalidates a concurrent active session before the row goes", async () => {
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
// The "concurrent" session lives in another browser, say. The
// route calls destroyAllSessions first, so that cookie can no
// longer authenticate a subsequent read after the call returns.
const otherSession = await prisma.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" }));
expect(res.status).toBe(200);
// Try to look up the concurrent session — it is gone.
const lookup = await prisma.session.findUnique({
where: { id: otherSession.id },
});
expect(lookup).toBeNull();
});
it("returns 422 when the confirmation string is missing", async () => {
await seedUserWithSession();
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({}));
expect(res.status).toBe(422);
});
it("returns 422 when the confirmation string is wrong", async () => {
await seedUserWithSession();
const prisma = getPrismaClient();
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({ confirm: "DELETE" }));
expect(res.status).toBe(422);
// The user row survived the failed call.
const rows = await prisma.user.count();
expect(rows).toBe(1);
});
it("returns 401 when called without a session cookie", async () => {
cookieJar.clear();
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" }));
expect(res.status).toBe(401);
});
it("guards against the last admin deleting themselves", async () => {
// Single-admin instance — the route should refuse the delete and
// surface a 400 instead of orphaning the install.
await seedUserWithSession({ role: "ADMIN", username: "solo-admin" });
const prisma = getPrismaClient();
const { DELETE } = await import("@/app/api/settings/account/route");
const res = await DELETE(makeRequest({ confirm: "DELETE_ACCOUNT" }));
expect(res.status).toBe(400);
const stillThere = await prisma.user.count({ where: { role: "ADMIN" } });
expect(stillThere).toBe(1);
});
});