mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
0d4b79d91b
commit
255cbf5fe0
10 files changed
+550
-52
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user