fix(insights): gate the assessment warm on insightStatus, not coach

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) <noreply@anthropic.com>
(cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 17:01:14 +02:00
1 parent 18cde65af7
commit 4feac6b7e4
3 files changed
+127 -3

No files matched your search

@@ -44,6 +44,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// 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",
];
@@ -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<Response>;
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();
});
});
+4 -3
View File
@@ -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