mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(vaccinations): keep the booster mint inside the sections the grant opens
`POST /api/vaccinations/{id}/booster` is declared `("write", "profile")`, and
that is right for the act — filing the Impfpass and arming the booster it
suggests are one thing to the person doing them. The row it writes is not: a
`MeasurementReminder` belongs to the measurements section, every direct route
over the model declares it there, and creating one needs MANAGE. So a WRITE
grant scoped to the health background alone produced a reminder on the owner's
checkup list that the delegate could neither read nor change through the
section that owns it.
The seam is fenced the way the visits service fences a checkup closure, with
one difference stated at the call site: this refuses rather than skips. There
the closure is one effect of a larger save and reporting it as not done keeps
the save honest; here the reminder is the whole request, and a 201 over a row
that was never written would tell the person their booster is planned while
nothing rings. The prompt that raises the call asks the same question client-
side, so a delegate outside the section is not offered it in the first place;
the dose itself still logs either way.
`vaccination-booster-scoped-delegate.test.ts` drives the owner, a delegate
holding both sections, a profile-only delegate and a whole-record one. The
scoped-refusal leg fails against the previous handler, which answered 201 and
left the row.
This commit is contained in:
1 parent
0ee26625be
commit
1b74bf8b54
5 files changed
+231
-7
No files matched your search
+28
-3
@@ -14190,12 +14190,19 @@ paths:
|
||||
tags:
|
||||
- Records
|
||||
summary: Plan a booster reminder from a dose
|
||||
description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin:
|
||||
description: >-
|
||||
Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin:
|
||||
VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the
|
||||
preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the
|
||||
existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A
|
||||
free-text-only dose has no antigen to remind on and is refused with 422. Audits as
|
||||
`vaccination.booster.planned`."
|
||||
`vaccination.booster.planned`.
|
||||
|
||||
|
||||
The operation is declared under the health background and the row it mints belongs to the measurements section,
|
||||
so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403
|
||||
`vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does
|
||||
not.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -14222,7 +14229,25 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MintVaccinationBoosterEnvelope"
|
||||
"401": *a1
|
||||
"403": *a8
|
||||
"403":
|
||||
description: >-
|
||||
Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there
|
||||
(`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.
|
||||
|
||||
|
||||
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.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404": *a25
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
|
||||
@@ -13,11 +13,21 @@
|
||||
* even when a delegate is transcribing. The antigen is read from the dose's
|
||||
* catalogue entry server-side, never from the body — a client cannot key a
|
||||
* reminder onto an antigen the dose does not contain.
|
||||
*
|
||||
* The row it mints is a `MeasurementReminder`, and that model belongs to the
|
||||
* `measurements` section: every direct route over it declares that domain, and
|
||||
* creating one needs MANAGE there. So the act crosses a seam, and a grant
|
||||
* scoped to the health background alone was never consent for the other side
|
||||
* of it — it would produce a row on the owner's checkup list that the delegate
|
||||
* can neither read nor change through the section that owns it. The seam is
|
||||
* fenced the way the visits service fences a checkup closure: the mint runs
|
||||
* only while the caller is inside `measurements`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
// Refused rather than skipped, and that is the difference from the visit's
|
||||
// checkup closure: there the closure is one effect of a larger save and
|
||||
// saying "not done" keeps the save honest, while here the reminder IS the
|
||||
// request. Reporting success over a row that was never written would tell
|
||||
// the person their booster is planned when nothing rings.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
if (!visible("measurements")) {
|
||||
annotate({ meta: { sharing_refusal: "booster_out_of_scope" } });
|
||||
return apiError(
|
||||
"Planning a booster needs access to this record's measurements",
|
||||
403,
|
||||
{ errorCode: "vaccination.booster-out-of-scope" },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@ export function VaccinationsView() {
|
||||
// next door.
|
||||
const canAddDose = canWriteDomain("profile");
|
||||
const canManageProfile = canManageDomain("profile");
|
||||
// Planning the booster mints a `MeasurementReminder`, which lives in the
|
||||
// measurements section rather than this one. A grant that opened only the
|
||||
// health background is refused there, so the offer is not made.
|
||||
const canPlanBooster = canWriteDomain("measurements");
|
||||
const { data, isLoading, isError, refetch } = useVaccinations();
|
||||
const records = data?.vaccinations ?? [];
|
||||
|
||||
@@ -91,7 +95,8 @@ export function VaccinationsView() {
|
||||
onCreated={(created) => {
|
||||
// Offer the booster only when the catalogue entry carries an
|
||||
// interval; a free-text or one-off dose never prompts.
|
||||
if (boosterOfferFor(created)) setBoosterFor(created);
|
||||
if (canPlanBooster && boosterOfferFor(created))
|
||||
setBoosterFor(created);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -362,14 +362,17 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Records"],
|
||||
summary: "Plan a booster reminder from a dose",
|
||||
description:
|
||||
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.",
|
||||
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" +
|
||||
"The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: vaccinationBoosterRequest } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
...recordRefusal(
|
||||
"Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.",
|
||||
),
|
||||
"200": {
|
||||
description: "Existing booster reminder re-anchored.",
|
||||
content: {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* `POST /api/vaccinations/{id}/booster` crosses a section boundary, and the
|
||||
* grant has to be on both sides of it.
|
||||
*
|
||||
* The operation is declared `("write", "profile")`, which is right for the act
|
||||
* — filing the Impfpass and arming the booster it suggests are one thing to
|
||||
* the person doing them. The ROW it mints is not: a `MeasurementReminder`
|
||||
* belongs to the measurements section, every direct route over that model says
|
||||
* so, and creating one there needs MANAGE. So a grant opening the health
|
||||
* background alone would otherwise produce a reminder on the owner's checkup
|
||||
* list that the delegate can neither read nor change through the section that
|
||||
* owns it.
|
||||
*
|
||||
* Refused rather than silently skipped: the reminder is the whole request, and
|
||||
* a 201 over a row that was never written would tell the person their booster
|
||||
* is planned while nothing rings.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
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 = "booster-scope-owner";
|
||||
const DELEGATE_ID = "booster-scope-delegate";
|
||||
|
||||
/** A catalogue antigen that carries a booster interval. */
|
||||
const ANTIGEN = "tetanus";
|
||||
|
||||
let doseId: string;
|
||||
|
||||
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: `booster-scope-${name}`,
|
||||
email: `booster-scope-${name}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
const dose = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
occurredAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
antigenSlug: ANTIGEN,
|
||||
},
|
||||
});
|
||||
doseId = dose.id;
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function switchInto(
|
||||
access: "WRITE" | "MANAGE",
|
||||
scope: ShareDomain[] | null,
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: DELEGATE_ID,
|
||||
access,
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
||||
const session = await signIn(DELEGATE_ID);
|
||||
await switchSessionTo(session.id, OWNER_ID);
|
||||
}
|
||||
|
||||
async function planBooster(): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/vaccinations/[id]/booster/route");
|
||||
return POST(
|
||||
new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: doseId }) },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedRecords();
|
||||
});
|
||||
|
||||
describe("planning a booster asks for the section the reminder lives in", () => {
|
||||
it("mints for the owner", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("mints for a delegate holding both sections", async () => {
|
||||
await switchInto("WRITE", ["profile", "measurements"]);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a profile-only delegate and writes no reminder", async () => {
|
||||
await switchInto("WRITE", ["profile"]);
|
||||
const response = await planBooster();
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("vaccination.booster-out-of-scope");
|
||||
expect(
|
||||
await getPrismaClient().measurementReminder.count({
|
||||
where: { userId: OWNER_ID },
|
||||
}),
|
||||
"no reminder was left on the owner's checkup list",
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("mints for a whole-record delegate", async () => {
|
||||
await switchInto("MANAGE", null);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user