merge(FIX-E): v1.20.2 audit fixes

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:28:43 +02:00
commit ca1cf53d2c
19 files changed
+223 -30

No files matched your search

@@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => {
expect(json.data.events).toHaveLength(10);
});
it("rejects an invalid level value", async () => {
it("rejects an invalid level value with a 422 (not a thrown 500)", async () => {
appendLogEvent(makeEvent());
// Invalid `level` — Zod parse should throw and the apiHandler test
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
// error-translator). We simply assert the call rejects.
await expect(GET(req("level=critical"))).rejects.toThrow();
// Invalid `level` — the query is parsed with `safeParse` and returns the
// standard 422 multi-issue envelope rather than throwing (which the
// apiHandler catch ladder would otherwise surface as a 500 + incident).
const res = await GET(req("level=critical"));
expect(res.status).toBe(422);
const json = (await res.json()) as {
data: null;
error: string;
details: { issues: unknown[] };
};
expect(json.data).toBeNull();
expect(json.error).toBe("Validation failed");
expect(json.details.issues.length).toBeGreaterThan(0);
});
});
+4 -2
View File
@@ -1,5 +1,5 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
import { redactSecrets } from "@/lib/logging/redact";
@@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.app-logs.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
traceId: searchParams.get("traceId") ?? undefined,
level: searchParams.get("level") ?? undefined,
action: searchParams.get("action") ?? undefined,
@@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
until: searchParams.get("until") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
const events = readLogBuffer({
traceId: parsed.traceId,
+4 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { redactSecrets } from "@/lib/logging/redact";
import { NextRequest } from "next/server";
@@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.audit-log.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
filter: searchParams.get("filter") ?? undefined,
@@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
// Resolve perPage: clamp to allowed set, fall back to 50.
const perPageResolved =
+4 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.host-metrics.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
since: searchParams.get("since") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
const sinceMs = presetToMs(parsed.since);
const cutoff = new Date(Date.now() - sinceMs);
+1 -1
View File
@@ -4,7 +4,7 @@
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
* for the calling user's configured AI provider. The surface stays dark for
* codex-only / text-only-model users.
* users with no provider configured and no local-OCR opt-in.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -0,0 +1,129 @@
/**
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
*
* Focus: the text-mode structuring pass reserves the proportionate text budget
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
* on a clean extraction failure it refunds the reservation in full rather than
* charging it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAuth: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-26"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
"@/lib/labs/ocr-extract",
);
return { ...actual, runOcrExtraction: vi.fn() };
});
import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { resolveTextProvider } from "@/lib/labs/ocr-capability";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function textReq(text = "Glucose 95 mg/dL"): Request {
return new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mode: "text", text }),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
} as never);
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [{ providerType: "openai", instance: {} as never }],
pick: {
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
},
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
} as never);
});
describe("POST /api/labs/ocr/extract — text mode budget", () => {
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq());
expect(res.status).toBe(200);
// The reservation must use the text ceiling — proven distinct from the
// vision ceiling so a future merge can't silently re-point it.
expect(AI_BUDGETS.ocrExtractText.maxTokens).toBeLessThan(
AI_BUDGETS.ocrExtract.maxTokens ?? Infinity,
);
expect(reserveBudget).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
"2026-06-26",
);
});
it("refunds the reservation in full on a clean extract failure", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(
new OcrExtractError("unreadable"),
);
const res = await POST(textReq());
expect(res.status).toBe(422);
// actual spend reconciles to 0 — the failed structuring pass is refunded,
// not charged at the reserved estimate.
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
0,
"2026-06-26",
);
});
});
+10 -9
View File
@@ -138,12 +138,14 @@ async function handleTextExtract(
});
}
// Budget (text is cheaper than vision; reuse the same ceiling as the upper
// bound — a text-only structuring call always spends at most this).
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
// a vision call, so it reserves the proportionate text ceiling rather than
// the vision budget. Over-charging the vision rate for a text call would
// exhaust the day budget against spend that never happened.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtract.maxTokens,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
);
if (!reservation.allowed) {
@@ -163,6 +165,8 @@ async function handleTextExtract(
providerType: pick.providerType,
ocrText: parsed.data.text,
});
// A clean structuring pass spent (at most) the reservation; the provider
// already billed it, so reconcile against the reserved estimate.
await reconcileSpend(
userId,
reservation.reserved,
@@ -171,12 +175,9 @@ async function handleTextExtract(
);
return apiSuccess(result);
} catch (err) {
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
);
// A failed structuring call produced no usable rows; mirror the vision
// path and refund the reservation in full rather than charging it.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",