mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user