diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..b2586c84e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1764,6 +1764,15 @@ paths: type: string format: binary "401": *a1 + "403": + description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/check-user: @@ -12203,8 +12212,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -12426,8 +12442,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 6cc215f33..3cf954223 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -20,7 +20,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { backupPayloadSchema } from "@/lib/validations/backup"; -import { errorEnvelope, stdResponses } from "./shared"; +import { + errorEnvelope, + SHARING_NOT_PERMITTED_DESCRIPTION, + stdResponses, +} from "./shared"; const exportQuery = z .object({ @@ -305,6 +309,10 @@ export const exportPaths: NonNullable = { }, }, }, + "403": { + description: SHARING_NOT_PERMITTED_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5edde619c..224f66438 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -19,6 +19,7 @@ import { dataEnvelope, errorEnvelope, recordRefusal, + SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; @@ -615,7 +616,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -801,7 +803,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43b625a9e..eb9f900b1 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -379,6 +379,25 @@ export function idempotentWrite(): IdempotentConflictResponse { export const SHARING_ACCESS_DENIED_DESCRIPTION = "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it."; +/** + * The OTHER sharing refusal, for a route that names no record at all. + * + * A route resolving through `requireAuth` serves the caller and only the + * caller, and refuses outright while the browser is inside somebody else's + * record rather than quietly answering with the caller's own rows. That + * posture matters most on the routes where being wrong is unrecoverable — the + * record wipe, the account deletion, the encrypted archive — and it was + * documented nowhere on them. A client that switches records has to know which + * of its calls stop working, and finding out by wiping the wrong record is not + * a contract. + * + * One sentence, spliced onto whatever else the operation's 403 already says, + * for the same reason the delegable refusal is one string: a paraphrase per + * path invites a client to tell them apart. + */ +export const SHARING_NOT_PERMITTED_DESCRIPTION = + "Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it."; + /** * The 403 above, optionally sharing the status with the reasons a route already * refuses for. diff --git a/tests/integration/switched-session-destructive-refusal.test.ts b/tests/integration/switched-session-destructive-refusal.test.ts new file mode 100644 index 000000000..dfd48f675 --- /dev/null +++ b/tests/integration/switched-session-destructive-refusal.test.ts @@ -0,0 +1,183 @@ +/** + * 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", + ); + }); +});