feat(sharing): open the measurement reads to a delegate

Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.

Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.

The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.

Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 16:30:46 +02:00
1 parent 0d4b79d91b
commit 255cbf5fe0
10 files changed
+550 -52

No files matched your search

+23 -6
View File
@@ -234,16 +234,33 @@ function exportedFunctionNames(rel: string): string[] {
/**
* Route modules permitted to act on a record that may not be the caller's.
*
* EMPTY, and correct empty. The resolver landed with the auth layer and
* deliberately touched no route file; the first members arrive when routes
* migrate, each one carrying a line in the classification record that says why
* it may. A route absent from that record cannot enter this list.
* Every member carries a line in the classification record that says why it
* may. A route absent from that record cannot enter this list.
*
* The value is the reason, in one line, in the maintainer's words. Not the
* route's description — the argument for why a delegate reading or writing
* through it cannot extend their own reach.
*
* Note what a member means: the MODULE reaches the resolver, and in every case
* below that is the GET arm alone. The write arms in these same files still
* resolve through `requireAuth()`, which refuses outright while a switch is
* active, so admitting a file here admits a read and nothing more. A delegable
* write is a separate decision that would have to be made at each of them.
*/
const DELEGABLE_ROUTES: Record<string, string> = {};
const DELEGABLE_ROUTES: Record<string, string> = {
"app/api/measurements/route.ts":
"The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id` and offers no control affordance; the POST arms in the same file keep `requireAuth()` and so refuse under a switch.",
"app/api/measurements/[id]/route.ts":
"One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.",
"app/api/measurements/series/route.ts":
"A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.",
"app/api/measurements/series-batch/route.ts":
"The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.",
"app/api/measurement-reminders/route.ts":
"The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
"app/api/measurement-reminders/[id]/route.ts":
"One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.",
};
/**
* Route modules that deliberately resolve the ACTOR and keep working under a
@@ -266,7 +283,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* a formality by accident: every addition has to be counted here as well as
* listed above, which is one more place a careless admission has to pass.
*/
const FROZEN_ENTRY_COUNT = 1;
const FROZEN_ENTRY_COUNT = 7;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -10,7 +10,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
@@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise<string> {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const reminder = await prisma.measurementReminder.findFirst({
+2 -2
View File
@@ -8,7 +8,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
@@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise<string> {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
@@ -54,6 +54,7 @@ vi.mock("next/headers", () => ({
}));
import { GET } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -80,6 +81,9 @@ beforeEach(() => {
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.0 — the 422 breadcrumb writes through the `auditLog` helper, whose
// returned promise the route attaches a `.catch` to.
vi.mocked(auditLog).mockResolvedValue(undefined);
// v1.29.6 — null source-priority blob → default rank ladders for the
// groupBy=day canonical-source collapse.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
@@ -30,9 +30,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -53,6 +51,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { Prisma } from "@/generated/prisma/client";
@@ -71,6 +70,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper now, because that helper is the only writer that stamps
// `actorUserId`. `resetAllMocks` above clears the factory's implementation,
// so the resolved promise the route awaits has to be restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
function getReq(qs: string): NextRequest {
@@ -121,18 +125,18 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.list.validation-failed");
expect(auditLog).toHaveBeenCalledTimes(1);
// v1.36.0 — through the helper, which files the row under the resolved
// record and stamps the delegate as actor when there is one. The bare
// `prisma.auditLog.create` this replaced could not do the second half.
expect(auditLog).toHaveBeenCalledWith(
"measurements.list.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
});
+12 -13
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { auditLog } from "@/lib/auth/audit";
@@ -58,7 +58,7 @@ import type {
import { Prisma } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
@@ -78,17 +78,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("measurements.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -22,7 +22,7 @@
* client keeps sleep on the dedicated route.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { returnAllZodIssues } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -33,7 +33,7 @@ import {
} from "@/lib/validations/series-batch";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = seriesBatchQuerySchema.safeParse(params);
+13 -13
View File
@@ -11,7 +11,8 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
@@ -159,7 +160,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -193,17 +194,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("measurements.series.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -0,0 +1,474 @@
/**
* The migrated routes, driven as routes, against real Postgres.
*
* Every case below imports the shipped `GET` export and calls it. Nothing is
* reimplemented: the real `apiHandler`, the real `requireRecordAuth`, the real
* grant table, the real Prisma queries. That matters more here than anywhere
* else in this feature, because the thing being asserted is a substitution
* that happens ABOVE the handler body — a test that rebuilt the handler would
* be testing its own copy of the substitution and would keep passing after the
* route stopped performing it.
*
* Four questions per domain, and the fourth is the one that decides whether
* this release is safe to ship:
*
* 1. a delegate with a live READ grant reads the OWNER's rows;
* 2. the same delegate without a grant is refused `sharing.access.denied`;
* 3. after revocation the very NEXT request is refused — no re-login;
* 4. a caller who never switched gets exactly what they got before.
*
* (4) is the regression. Forty-four routes changed; a person who shares
* nothing must not be able to tell.
*
* Owner and delegate are BOTH seeded with data in every case, and the
* assertions name the owner's values and the delegate's separately. An empty
* result satisfies "the delegate's rows are absent" while hiding the exact
* failure this feature exists to avoid.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } 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),
}));
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `deleg-${suffix}`,
email: `deleg-${suffix}@example.test`,
displayName: `Deleg ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
/**
* A live grant and a session already inside the owner's record.
*
* Written straight to the tables rather than driven through invite / accept /
* switch, because `sharing-lifecycle.test.ts` already proves those three
* routes produce exactly these rows and repeating the handshake once per
* domain would buy nothing but wall-clock. The rows are the contract; this
* file is about what the migrated routes do once they exist.
*/
async function switchInto(ownerId: string, delegateId: string) {
const grant = await getPrismaClient().accountGrant.create({
data: {
grantorId: ownerId,
granteeId: delegateId,
access: "READ",
acceptedAt: new Date(),
},
});
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
return { grant, session };
}
/** The delegate is inside the record, but the grant is gone. */
async function revokeGrant(grantId: string) {
await getPrismaClient().accountGrant.update({
where: { id: grantId },
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
});
}
/**
* A route's `GET` export, seen from outside.
*
* Each shipped handler declares its own params shape (`{ id: string }` and so
* on), which is exactly the sort of per-route difference a driver like this one
* has no business knowing. The cast at each call site narrows to this, and the
* `params` object handed in is checked by the route's own Zod / lookup logic
* one line later — a wrong id produces a 404 from the handler, not a type
* error here.
*/
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
function get(url: string): NextRequest {
return new NextRequest(`http://localhost${url}`, { method: "GET" });
}
async function call(
handler: Handler,
url: string,
params: Record<string, string> = {},
): Promise<Response> {
return handler(get(url), { params: Promise.resolve(params) });
}
async function body(response: Response): Promise<{
data: unknown;
error: unknown;
meta?: { errorCode?: string };
}> {
return response.json();
}
/** The stable refusal, asserted by code rather than by status alone. */
async function expectDenied(response: Response) {
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("sharing.access.denied");
}
/**
* The four-part contract, run against one route.
*
* `seed` writes one row for whichever user it is handed and returns the value
* that identifies it in the response; `read` pulls those identifying values
* back out of the response body. The domain suites below supply both, so each
* one is four assertions of substance rather than four of shape.
*/
function contract<T>(
name: string,
route: {
call: () => Promise<Response>;
seed: (userId: string, marker: T) => Promise<void>;
read: (payload: never) => T[];
ownerMarker: T;
delegateMarker: T;
},
) {
describe(name, () => {
it("serves the owner's rows to a delegate holding a live grant", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await route.seed(owner.id, route.ownerMarker);
await route.seed(delegate.id, route.delegateMarker);
await switchInto(owner.id, delegate.id);
const response = await route.call();
expect(response.status).toBe(200);
const rows = route.read((await body(response)).data as never);
expect(rows).toContainEqual(route.ownerMarker);
// The delegate's own row is seeded and must be absent. Asserting the
// owner's presence alone would pass on a handler that returned
// everybody's rows.
expect(rows).not.toContainEqual(route.delegateMarker);
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
await route.seed(owner.id, route.ownerMarker);
const session = await signIn(stranger.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
await expectDenied(await route.call());
});
it("refuses the next request after the grant is revoked", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await route.seed(owner.id, route.ownerMarker);
const { grant } = await switchInto(owner.id, delegate.id);
expect((await route.call()).status).toBe(200);
// No sign-out, no new session, no cache flush. The same browser, the
// next request.
await revokeGrant(grant.id);
await expectDenied(await route.call());
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await route.seed(plain.id, route.ownerMarker);
await signIn(plain.id);
const response = await route.call();
expect(response.status).toBe(200);
expect(route.read((await body(response)).data as never)).toContainEqual(
route.ownerMarker,
);
});
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
/* -------------------------------------------------------------------------- */
/* Measurements */
/* -------------------------------------------------------------------------- */
async function seedWeight(userId: string, value: number) {
await getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
measuredAt: new Date("2026-07-15T08:00:00Z"),
source: "MANUAL",
},
});
}
contract<number>("GET /api/measurements", {
call: async () => {
const { GET } = await import("@/app/api/measurements/route");
return call(GET as Handler, "/api/measurements");
},
seed: seedWeight,
read: (payload: { measurements: { value: number }[] }) =>
payload.measurements.map((m) => m.value),
ownerMarker: 81,
delegateMarker: 64,
});
contract<number>("GET /api/measurements/series", {
call: async () => {
const { GET } = await import("@/app/api/measurements/series/route");
return call(GET as Handler, "/api/measurements/series?kind=weight&days=90");
},
seed: seedWeight,
read: (payload: { points: { value: number }[] }) =>
payload.points.map((p) => p.value),
ownerMarker: 81,
delegateMarker: 64,
});
contract<number>("GET /api/measurements/series-batch", {
call: async () => {
const { GET } = await import("@/app/api/measurements/series-batch/route");
return call(
GET as Handler,
"/api/measurements/series-batch?types=WEIGHT&from=2026-01-01T00:00:00Z&to=2026-12-31T00:00:00Z",
);
},
seed: seedWeight,
read: (payload: { series: Record<string, { value: number }[]> }) =>
(payload.series.WEIGHT ?? []).map((row) => row.value),
ownerMarker: 81,
delegateMarker: 64,
});
describe("GET /api/measurements/[id]", () => {
it("serves one of the owner's readings and refuses the delegate's reach past it", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
await seedWeight(delegate.id, 64);
const ownerRow = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/measurements/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/measurements/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ value: 81 });
// The fetch-then-guard arm now guards against the OWNER. The delegate's
// own reading is out of scope while they are inside somebody else's
// record — which is the correct answer and a surprising one, so it is
// asserted rather than assumed.
const theirs = await call(
GET as Handler,
`/api/measurements/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/measurements/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedWeight(plain.id, 77);
const row = await getPrismaClient().measurement.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/measurements/[id]/route");
const response = await call(GET as Handler, `/api/measurements/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ value: 77 });
});
});
describe("the 422 breadcrumb a delegate leaves on the owner's record", () => {
it("files under the owner and names the delegate as the actor", async () => {
// The one write a delegable measurements GET performs. It was a bare
// `prisma.auditLog.create` before the migration, which files the row with
// `actorUserId` NULL — indistinguishable from the owner having typed the
// bad query themselves.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/measurements/route");
const refused = await call(
GET as Handler,
"/api/measurements?limit=not-a-number",
);
expect(refused.status).toBe(422);
await vi.waitFor(async () => {
const row = await getPrismaClient().auditLog.findFirst({
where: { action: "measurements.list.validation-failed" },
});
expect(row?.userId).toBe(owner.id);
expect(row?.actorUserId).toBe(delegate.id);
});
});
});
/* -------------------------------------------------------------------------- */
/* Measurement reminders */
/* -------------------------------------------------------------------------- */
async function seedReminder(userId: string, label: string) {
await getPrismaClient().measurementReminder.create({
data: {
userId,
label,
measurementType: "WEIGHT",
intervalDays: 7,
enabled: true,
},
});
}
contract<string>("GET /api/measurement-reminders", {
call: async () => {
const { GET } = await import("@/app/api/measurement-reminders/route");
return call(GET as Handler, "/api/measurement-reminders");
},
seed: seedReminder,
read: (payload: { label: string }[]) => payload.map((r) => r.label),
ownerMarker: "owner weigh-in",
delegateMarker: "delegate weigh-in",
});
describe("GET /api/measurement-reminders/[id]", () => {
it("serves the owner's reminder and refuses once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedReminder(owner.id, "owner weigh-in");
await seedReminder(delegate.id, "delegate weigh-in");
const ownerRow =
await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow =
await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/measurement-reminders/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/measurement-reminders/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ label: "owner weigh-in" });
const theirs = await call(
GET as Handler,
`/api/measurement-reminders/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/measurement-reminders/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedReminder(plain.id, "plain weigh-in");
const row = await getPrismaClient().measurementReminder.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/measurement-reminders/[id]/route");
const response = await call(
GET as Handler,
`/api/measurement-reminders/${row.id}`,
{ id: row.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
label: "plain weigh-in",
});
});
});