mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
180 lines
6.0 KiB
TypeScript
180 lines
6.0 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),
|
|
})),
|
|
};
|
|
});
|
|
|
|
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",
|
|
);
|
|
});
|
|
});
|