From 6eef3ab21c00a33dd926ec916bbc5ec4efac9a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 01:57:54 +0200 Subject: [PATCH] fix(workouts): gate the workout read APIs behind the workouts module GET /api/workouts and GET /api/workouts/{id} returned a disabled- module account's workout list and detail to any valid Bearer or cookie session. Both now require the workouts module and return the 403 module.disabled envelope when it is off. The batch INGEST route (POST /api/workouts/batch) stays ungated by design: synced workouts must keep landing in storage even while the surface is hidden, so re-enabling the module reveals a complete history rather than a gap. --- src/app/api/workouts/[id]/route.ts | 5 ++++ .../__tests__/canonical-dedup.test.ts | 28 +++++++++++++++++++ src/app/api/workouts/__tests__/detail.test.ts | 28 +++++++++++++++++++ src/app/api/workouts/route.ts | 8 ++++++ 4 files changed, 69 insertions(+) diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index d2b7141a9..d4f55e537 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -29,6 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { requireModuleEnabled } from "@/lib/modules/gate"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,6 +40,10 @@ export const GET = apiHandler( annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); + // v1.18.0 B1 — gate the detail surface behind the workouts module. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const row = await prisma.workout.findUnique({ where: { id }, include: { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index de21ca911..4d360b71c 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -113,6 +123,10 @@ describe("GET /api/workouts — canonical dedup", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { @@ -139,6 +153,20 @@ describe("GET /api/workouts — canonical dedup", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest()); + expect(res.status).toBe(403); + // The list query never runs once the gate refuses. + expect(prisma.workout.findMany).not.toHaveBeenCalled(); + }); + it("filters by ownership — only the caller's rows enter the picker", async () => { // The mock returns whatever the route asked Prisma for; the route's // `where: { userId: user.id }` filter is what enforces ownership. diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 683a53c5d..f3de4f6d1 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -94,6 +104,10 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("returns the workout with iOS-contract field names", async () => { @@ -123,6 +137,20 @@ describe("GET /api/workouts/{id}", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(403); + // The detail query never runs once the gate refuses. + expect(prisma.workout.findUnique).not.toHaveBeenCalled(); + }); + it("returns 404 when the row belongs to another user", async () => { vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ ...BASE_ROW, diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 4fe739e6a..098a99990 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -55,6 +55,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; @@ -171,6 +172,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "workouts.list" } }); + // v1.18.0 B1 — the workouts surface is a toggleable module. A disabled + // account must not be able to read the list even with a valid Bearer + // token. The batch INGEST route stays ungated so synced data still + // lands while the surface is hidden. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const { searchParams } = new URL(request.url); const limit = Math.min( Math.max(