mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
184 lines
6.1 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|