mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
927496236a
commit
b6d3f9903f
5 files changed
+241
-5
No files matched your search
+25
-2
@@ -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:
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
recordRefusal,
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -615,7 +616,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user