Files
HealthLog/tests/integration/switched-session-destructive-refusal.test.ts
T
Marc-André Bombeck b6d3f9903f docs(api): publish the switch refusal on the wipe, the deletion and the encrypted export
The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.

They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.

One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
2026-09-10 10:49:12 +02:00

184 lines
6.1 KiB
TypeScript

/**
* The three destructive account routes, driven with a live record switch on.
*
* `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account`
* erases an account, and `POST /api/export/encrypted` puts a whole record into
* one file. None of them names a record: they resolve the CALLER and act on
* whatever that caller owns. So the question a switched session asks of them is
* not "may I do this to the record I am inside" — it is "what happens when the
* browser believes it is somewhere else and the route does not check".
*
* The answer must be a refusal, and the same one every undeclared route gives:
* 403, `sharing.not_permitted`, "cannot be used while acting on another
* account". Silently serving the delegate's own record is the failure this
* pins against, and it is the worst-shaped one in the product — a person who
* believes they are looking after somebody else's record presses "delete
* everything" and erases their own.
*
* Both halves are asserted: the status AND that nothing moved. A refusal that
* happens after the transaction would satisfy the first alone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } 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),
}));
const OWNER_ID = "switch-destructive-owner";
const DELEGATE_ID = "switch-destructive-delegate";
/** The delegate's own reading. It must still be there afterwards. */
async function seedRecords() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `switch-destructive-${name}`,
email: `switch-destructive-${name}@example.test`,
timezone: "UTC",
},
});
}
await prisma.measurement.create({
data: {
userId: DELEGATE_ID,
type: "WEIGHT",
value: 72,
unit: "kg",
measuredAt: new Date(),
},
});
}
/**
* Sign the delegate in and switch into the owner's record at MANAGE — the
* widest grant the product mints, so nothing below can be explained away by
* the grant simply being too narrow.
*/
async function switchIntoOwnersRecord() {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await getPrismaClient().session.create({
data: {
userId: DELEGATE_ID,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
await switchSessionTo(session.id, OWNER_ID);
}
/** The one refusal all three must give. */
async function expectSwitchRefusal(response: Response) {
expect(response.status).toBe(403);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
expect(body.error).toBe(
"This endpoint cannot be used while acting on another account",
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedRecords();
await switchIntoOwnersRecord();
});
describe("a switched session reaches none of the record-wide destructive routes", () => {
it("refuses the record wipe and wipes neither record", async () => {
const { DELETE } = await import("@/app/api/settings/data/route");
await expectSwitchRefusal(
await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
),
);
const prisma = getPrismaClient();
expect(
await prisma.measurement.count({ where: { userId: DELEGATE_ID } }),
"the delegate's own reading survived",
).toBe(1);
expect(
await prisma.user.count({
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
}),
).toBe(2);
});
it("refuses the account deletion and deletes neither account", async () => {
const { DELETE } = await import("@/app/api/settings/account/route");
await expectSwitchRefusal(
await DELETE(
new Request("http://localhost/api/settings/account", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
}) as never,
),
);
expect(
await getPrismaClient().user.count({
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
}),
).toBe(2);
});
it("refuses the encrypted export and hands out no archive", async () => {
const { POST } = await import("@/app/api/export/encrypted/route");
const response = await POST(
new Request("http://localhost/api/export/encrypted", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }),
}) as never,
);
await expectSwitchRefusal(response);
// Not an archive under any content type: a refusal that still streamed the
// bytes would read as a 403 to a status check and as an export to a user.
expect(response.headers.get("content-type")).not.toContain(
"application/octet-stream",
);
});
});