mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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.
This commit is contained in:
1 parent
8cc879f6dc
commit
6eef3ab21c
4 files changed
+69
No files matched your search
@@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
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.
|
||||
|
||||
@@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user