diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index f3ad80b47..18ed4790e 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -280,6 +280,18 @@ const DELEGABLE_ROUTES: Record = { "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", "app/api/anamnesis/facts/route.ts": "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", + "app/api/mood-entries/route.ts": + "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", + "app/api/mood-entries/[id]/route.ts": + "One mood entry of the record, fetch-then-guard against the resolved user.", + "app/api/custom-metrics/route.ts": + "The record's own metric definitions and their latest values. Every `where` carries the resolved user id.", + "app/api/custom-metrics/[id]/route.ts": + "One custom metric of the record, scoped by the resolved user id in the query itself.", + "app/api/custom-metrics/[id]/entries/route.ts": + "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.", + "app/api/personal-records/route.ts": + "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", }; /** @@ -303,7 +315,7 @@ const ACTOR_ROUTES: Record = { * 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 = 17; +const FROZEN_ENTRY_COUNT = 23; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 5c96553b2..b6963086c 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -35,7 +35,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; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index b50b2726c..7925b7d8b 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -38,7 +38,7 @@ function effectiveBound( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 06b8e7d5e..0d4a71e29 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics"; */ export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const rows = await prisma.customMetric.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index ce9843d91..a9166ad71 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -11,7 +11,7 @@ import { import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; @@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 1b54d3e0c..71717a25f 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -51,9 +51,7 @@ vi.mock("@/lib/mood/tag-links", async () => { }); 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/idempotency", () => ({ withIdempotency: (fn: (...args: Args) => Promise) => @@ -80,6 +78,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 { createTagLinks } from "@/lib/mood/tag-links"; @@ -107,6 +106,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` clears the factory's implementation, so the + // resolved promise the route attaches a `.catch` to is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); // Re-establish the interactive-transaction passthrough after the reset. vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient), @@ -152,17 +156,14 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { const res = await GET(getReq("mood=junk&sortBy=garbage")); 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 }; - }; - expect(call.data.action).toBe("mood-entries.list.validation-failed"); + expect(auditLog).toHaveBeenCalledWith( + "mood-entries.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("mood=junk&sortBy=garbage")); expect(res.status).toBe(422); }); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 2a98b2ba6..699baac0b 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -19,7 +19,7 @@ import { } from "@/lib/validations/external-id"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; @@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] { } 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 = listMoodEntriesSchema.safeParse(params); @@ -61,17 +61,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "mood-entries.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("mood-entries.list.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "mood.list.invalid", }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 65a971a0e..54847fce1 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -16,7 +16,7 @@ */ import { prisma } from "@/lib/db"; import type { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100; const MAX_LIMIT = 500; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); annotate({ action: { name: "personalRecords.list" } }); const { searchParams } = new URL(request.url); diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 38d4fbce3..86177c543 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -840,3 +840,214 @@ describe("GET /api/mental-health/assessments", () => { expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]); }); }); + +/* -------------------------------------------------------------------------- */ +/* Mood, custom metrics, personal records */ +/* -------------------------------------------------------------------------- */ + +async function seedMood(userId: string, score: number) { + await getPrismaClient().moodEntry.create({ + data: { + userId, + date: "2026-07-14", + mood: "GUT", + score, + moodLoggedAt: new Date("2026-07-14T19:00:00Z"), + }, + }); +} + +contract("GET /api/mood-entries", { + call: async () => { + const { GET } = await import("@/app/api/mood-entries/route"); + return call(GET as Handler, "/api/mood-entries"); + }, + seed: seedMood, + read: (payload: { entries: { score: number }[] }) => + payload.entries.map((e) => e.score), + ownerMarker: 4, + delegateMarker: 2, +}); + +describe("GET /api/mood-entries/[id]", () => { + it("resolves against the owner and refuses once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMood(owner.id, 4); + await seedMood(delegate.id, 2); + const ownerRow = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/mood-entries/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ score: 4 }); + + const theirs = await call( + GET as Handler, + `/api/mood-entries/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/mood-entries/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedMood(plain.id, 5); + const row = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const response = await call(GET as Handler, `/api/mood-entries/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ score: 5 }); + }); +}); + +async function seedCustomMetric(userId: string, name: string) { + const metric = await getPrismaClient().customMetric.create({ + data: { userId, name, unit: "ml" }, + }); + await getPrismaClient().customMetricEntry.create({ + data: { + userId, + customMetricId: metric.id, + value: 250, + unit: "ml", + measuredAt: new Date("2026-07-14T12:00:00Z"), + }, + }); +} + +contract("GET /api/custom-metrics", { + call: async () => { + const { GET } = await import("@/app/api/custom-metrics/route"); + return call(GET as Handler, "/api/custom-metrics"); + }, + seed: seedCustomMetric, + read: (payload: { customMetrics: { name: string }[] }) => + payload.customMetrics.map((m) => m.name), + ownerMarker: "owner-water", + delegateMarker: "delegate-water", +}); + +describe("GET /api/custom-metrics/[id] and its entries", () => { + it("reach the owner's metric and refuse once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCustomMetric(owner.id, "owner-water"); + await seedCustomMetric(delegate.id, "delegate-water"); + const ownerMetric = await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateMetric = + await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const detail = await import("@/app/api/custom-metrics/[id]/route"); + const entries = await import("@/app/api/custom-metrics/[id]/entries/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + detail.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}`, + { id: ownerMetric.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ name: "owner-water" }); + + // The entries route establishes ownership on the METRIC first and only + // then queries by `customMetricId`. That hop now resolves against the + // owner, so the delegate's own metric is unreachable from in here. + const theirEntries = await call( + entries.GET as Handler, + `/api/custom-metrics/${delegateMetric.id}/entries`, + { id: delegateMetric.id }, + ); + expect(theirEntries.status).toBe(404); + + const ownerEntries = await call( + entries.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}/entries`, + { id: ownerMetric.id }, + ); + expect(ownerEntries.status).toBe(200); + const payload = (await body(ownerEntries)).data as { + entries: { value: number }[]; + }; + expect(payload.entries.map((e) => e.value)).toEqual([250]); + + await revokeGrant(grant.id); + await expectDenied( + await call( + entries.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}/entries`, + { id: ownerMetric.id }, + ), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedCustomMetric(plain.id, "plain-water"); + const metric = await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/custom-metrics/[id]/route"); + const response = await call( + GET as Handler, + `/api/custom-metrics/${metric.id}`, + { id: metric.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ name: "plain-water" }); + }); +}); + +async function seedPersonalRecord(userId: string, value: number) { + await getPrismaClient().personalRecord.create({ + data: { + userId, + metricType: "WEIGHT", + direction: "MIN", + value, + unit: "kg", + achievedAt: new Date("2026-06-01T08:00:00Z"), + }, + }); +} + +contract("GET /api/personal-records", { + call: async () => { + const { GET } = await import("@/app/api/personal-records/route"); + return call(GET as Handler, "/api/personal-records"); + }, + seed: seedPersonalRecord, + read: (payload: { value: number }[]) => payload.map((r) => r.value), + ownerMarker: 79.5, + delegateMarker: 61.5, +});