From 4feac6b7e4218581a6471f46bc99438934d3f802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:53:54 +0200 Subject: [PATCH] fix(insights): gate the assessment warm on insightStatus, not coach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/insights/pregenerate warms the assessment cards — the comprehensive insight plus the specialised status cards — all of which the read-only status GETs gate on the insightStatus surface. It was gating on coach, so a user with assessments enabled but the Coach disabled got a 403 and could not warm. Move the gate to insightStatus to match the surface it actually serves, add it to the route-gate inventory allowlist, and cover the coach-disabled-still-warms path with a route test. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b) --- .../coach-route-gate-inventory.test.ts | 4 + .../pregenerate/__tests__/route.test.ts | 119 ++++++++++++++++++ src/app/api/insights/pregenerate/route.ts | 7 +- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/app/api/insights/pregenerate/__tests__/route.test.ts diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index f64973294..8b4b61ac7 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -44,6 +44,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // same `insightStatus` sub-flag as the seven specialised status routes. "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", + // v1.9.0 — on-demand full assessment warm. Warms the same assessment + // cards the status routes serve, so it gates on `insightStatus`, not + // `coach`: a user with assessments enabled but Coach disabled can warm. + "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", ]; diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts new file mode 100644 index 000000000..9b9cdb332 --- /dev/null +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Always allow the anti-spam bucket so the surface gate is what the test +// exercises. +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +// Stub the worker enqueue so the route test stays isolated from pg-boss. +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueForceWarm: vi.fn(async () => undefined), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/pregenerate"), + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("POST /api/insights/pregenerate", () => { + it("enqueues a warm when insightStatus is enabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { queued: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.queued).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callPost(makeReq()); + expect(res.status).toBe(401); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index a18936cda..ec5e41380 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -38,9 +38,10 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Same surface gate as the read-only status routes — when the operator - // disables the assistant surface there is nothing to warm. - await requireAssistantSurface("coach"); + // Gate on the same surface as the read-only status routes: this warms + // the assessment cards (the `insightStatus` surface), not the Coach. + // A user with assessments enabled but Coach disabled can still warm. + await requireAssistantSurface("insightStatus"); const userId = user.id; // Short per-user bucket so the bypassed nightly budget can't be abused