merge(reconcile): g1d-coachroutes audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 01:49:20 +02:00
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,
+7
View File
@@ -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");
});
});
+8
View File
@@ -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.