mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge(reconcile): g1d-coachroutes audit findings
This commit is contained in:
commit
372554bc11
8 files changed
+469
-1
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the about-me self-context GET/PUT enforce the two-layer
|
||||
* Coach module gate (operator availability AND the per-user
|
||||
* `disableCoach` opt-out) right after auth. A disabled module returns
|
||||
* the 403 `module.disabled` envelope before any self-context read or
|
||||
* write; an enabled module proceeds to the normal 200 path.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
upsert: vi.fn(async () => ({ updatedAt: new Date(0) })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => Buffer.from(s),
|
||||
}));
|
||||
const emptyCtx = {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(async () => emptyCtx),
|
||||
getPendingQuestionsForUser: vi.fn(async () => []),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
deriveClarifyingQuestions: vi.fn(async () => ({
|
||||
questions: [],
|
||||
source: "none",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const get = GET as unknown as () => Promise<Envelope>;
|
||||
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function putReq(): Request {
|
||||
return new Request("http://localhost/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aboutMe: "I run daily" }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("about-me module gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await get();
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
});
|
||||
|
||||
it("GET returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("PUT returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
});
|
||||
|
||||
it("PUT returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,18 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
@@ -59,9 +67,11 @@ vi.mock("@/lib/api-response", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
const gate = requireModuleEnabled as ReturnType<typeof vi.fn>;
|
||||
|
||||
function adoptRequest(body: { question?: string; answer: string }): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/adopt", {
|
||||
@@ -87,6 +97,29 @@ describe("POST /api/coach/about-me/adopt", () => {
|
||||
tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" });
|
||||
tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() });
|
||||
tx.$queryRaw.mockResolvedValue([{ id: "p1" }]);
|
||||
gate.mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
// v1.18.0 — Coach module gate (operator availability OR per-user
|
||||
// disableCoach) short-circuits before the locked read-modify-write.
|
||||
it("returns 403 module.disabled when Coach is disabled, without a write", async () => {
|
||||
gate.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(gate).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the read-modify-write inside one transaction with a row lock", async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ABOUT_ME_MAX_CHARS,
|
||||
@@ -93,6 +94,9 @@ function normalise(s: string): string {
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-about-me:adopt:${user.id}`,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the clarifying-questions GET/DELETE enforce the two-layer
|
||||
* Coach module gate (operator availability AND the per-user
|
||||
* `disableCoach` opt-out) right after auth. A disabled module returns
|
||||
* the 403 `module.disabled` envelope before any pending-question read or
|
||||
* mutation; an enabled module proceeds to the normal 200 path.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async () => ({ data: {}, error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getPendingQuestionsForUser: vi.fn(async () => ["q1"]),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
PENDING_QUESTION_MAX_CHARS: 200,
|
||||
}));
|
||||
|
||||
import { GET, DELETE } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const get = GET as unknown as () => Promise<Envelope>;
|
||||
const del = DELETE as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function delReq(): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/questions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("about-me questions module gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await get();
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
});
|
||||
|
||||
it("GET returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("DELETE returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
const res = await del(delReq());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
});
|
||||
|
||||
it("DELETE returns 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
const res = await del(delReq());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
PENDING_QUESTION_MAX_CHARS,
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const dismissSchema = z.object({
|
||||
/** Exact question text to dismiss. Omitted = dismiss all. */
|
||||
@@ -32,6 +33,9 @@ const dismissSchema = z.object({
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const questions = await getPendingQuestionsForUser(user.id);
|
||||
annotate({
|
||||
action: { name: "coach.about_me.questions.get" },
|
||||
@@ -42,6 +46,9 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
setPendingQuestionsForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
ABOUT_ME_FIELD_MAX_CHARS,
|
||||
ABOUT_ME_MAX_CHARS,
|
||||
@@ -54,6 +55,9 @@ const PUT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [ctx, pendingQuestions, row] = await Promise.all([
|
||||
getSelfContextForUser(user.id),
|
||||
@@ -89,6 +93,9 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.18.0 — Coach module gate (operator availability + disableCoach).
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-about-me:put:${user.id}`,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both
|
||||
* enforce the two-layer module gate (operator availability AND the
|
||||
* per-user `disableCoach` opt-out) right after auth. A disabled module
|
||||
* returns the 403 `module.disabled` envelope verbatim before any other
|
||||
* Coach dependency runs; an enabled module falls through to the existing
|
||||
* assistant-flag check. The suite pins both branches so a refactor can't
|
||||
* quietly drop the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// The legacy assistant-flag gate the handler reaches AFTER the module
|
||||
// gate. We throw a sentinel so an enabled module is observable by the
|
||||
// control reaching this call.
|
||||
class AssistantSentinel extends Error {}
|
||||
|
||||
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
requireAssistantSurface: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
|
||||
|
||||
// Everything else the module imports — stubbed harmlessly; none of it is
|
||||
// reached on the disabled branch, and the enabled branch stops at the
|
||||
// assistant sentinel above.
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(),
|
||||
enforceBudget: vi.fn(),
|
||||
recordSpend: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
const post = POST as unknown as (req: Request) => Promise<Envelope>;
|
||||
const get = GET as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const disabledResponse: Envelope = {
|
||||
data: null,
|
||||
error: 'Module "coach" is not enabled',
|
||||
status: 403,
|
||||
meta: { errorCode: "module.disabled", module: "coach" },
|
||||
};
|
||||
|
||||
function chatPostReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "hi" }),
|
||||
});
|
||||
}
|
||||
|
||||
function listReq(): Request {
|
||||
return new Request("http://localhost/api/insights/chat", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("coach chat module gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAssistantSurface.mockImplementation(async () => {
|
||||
throw new AssistantSentinel("reached assistant surface");
|
||||
});
|
||||
});
|
||||
|
||||
it("POST returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await post(chatPostReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(res.meta?.module).toBe("coach");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
// The gate short-circuits before the legacy assistant flag.
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST falls through to the assistant flag when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
|
||||
await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
|
||||
it("GET returns 403 module.disabled when Coach is disabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: disabledResponse,
|
||||
});
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET reaches 200 when Coach is enabled", async () => {
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
requireAssistantSurface.mockResolvedValue(undefined);
|
||||
|
||||
const res = await get(listReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import {
|
||||
@@ -151,6 +152,10 @@ function buildHistoryWindow(
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// v1.18.0 — two-layer module gate (operator availability + per-user
|
||||
// disableCoach) on top of the legacy assistant flag. 403 module.disabled.
|
||||
const gate = await requireModuleEnabled(auth.user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
// v1.4.31 — operator can disable the Coach surface app-wide.
|
||||
// Throws AssistantDisabledError → apiHandler returns 403 +
|
||||
// `errorCode: "assistant.disabled.coach"` per the iOS contract.
|
||||
@@ -687,6 +692,9 @@ export const POST = apiHandler(handleChatRequest);
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.18.0 — same two-layer module gate as the SSE POST.
|
||||
const gate = await requireModuleEnabled(auth.user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
// v1.4.31 — same gate as the SSE POST. Hiding the rail when
|
||||
// the operator has disabled Coach matches the FAB suppression
|
||||
// on the client.
|
||||
|
||||
Reference in new issue
Block a user