mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(documents): cover content index, assist/summary/index routes, backfill
P2-1. Unit tests for the token scheme (whole-word match, stopword drop, de-accent, no plaintext at rest, subkey follows the active key), HKDF subkey derivation, the suggest route (drafts only, writes nothing, provider-gated), the summary route (session-only, budget+audit only), the index route (vision transcribe→upsert, text posts OCR text, provider/local-OCR gated), and the backfill (no-provider/no-consent no-op, indexes, budget-reached resume).
This commit is contained in:
1 parent
5a0fd47eb5
commit
b210c743e9
6 files changed
+778
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Content-index route (Document vault P2). Pins: vision path transcribes then
|
||||
* upserts the index; text path indexes posted OCR text with no provider;
|
||||
* provider-gated on vision; only the index sibling is written (never facts).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { create: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
vi.mock("@/lib/documents/describe", () => ({
|
||||
transcribeDocument: vi.fn(),
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
const visionReq = (id: string) =>
|
||||
new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/index`), {
|
||||
method: "POST",
|
||||
});
|
||||
const textReq = (id: string, text: string) =>
|
||||
new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/index`), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
contentCodec: "binary2",
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
it("vision: transcribes then upserts the index, never facts", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "hemoglobin 14.2 leukocytes normal",
|
||||
} as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toMatchObject({
|
||||
documentId: "doc-1",
|
||||
indexed: true,
|
||||
tokenCount: 7,
|
||||
});
|
||||
expect(upsertContentIndex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "vision", documentId: "doc-1" }),
|
||||
);
|
||||
expect(prisma.extractedFact.create).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("vision: 422 without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("text: indexes posted OCR text with no provider egress", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
textReq("doc-1", "cholesterol total 190 mg dl") as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(upsertContentIndex).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "text-ocr", providerType: null }),
|
||||
);
|
||||
// No provider was resolved on the text path.
|
||||
expect(resolveVisionProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("text: 422 when local OCR is not enabled", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
textReq("doc-1", "some text") as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Filing-metadata assist (Document vault P2). Pins P2-D2: suggestions ONLY —
|
||||
* the route writes nothing (no fact staging, no status flip, no row update) —
|
||||
* and 422s cleanly with no provider.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), create: vi.fn(), count: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
vi.mock("@/lib/documents/assist", () => ({
|
||||
runDocumentAssist: vi.fn(),
|
||||
DocumentAssistError: class DocumentAssistError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
const visionReq = (id: string) =>
|
||||
new NextRequest(new URL(`http://localhost/api/documents/inbound/${id}/suggest`), {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
contentCodec: "binary2",
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/suggest", () => {
|
||||
it("422s without a vision provider and writes nothing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported");
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.create).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns drafts and never persists them", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(runDocumentAssist).mockResolvedValue({
|
||||
title: "City Lab — Blood panel",
|
||||
kind: "LAB_RESULT",
|
||||
documentDate: "2026-06-01",
|
||||
} as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.suggestions).toEqual({
|
||||
title: "City Lab — Blood panel",
|
||||
kind: "LAB_RESULT",
|
||||
documentDate: "2026-06-01",
|
||||
});
|
||||
// Writes NOTHING (P2-D2): no fact staging, no status flip, no index write.
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.create).not.toHaveBeenCalled();
|
||||
expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s an unknown document", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
|
||||
const res = await POST(visionReq("nope") as never, ctx("nope") as never);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3:
|
||||
* session-only — the ONLY persistent side effects are the AI-budget ledger and
|
||||
* the audit log; no row, index, or fact is written or mutated.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
vi.mock("@/lib/documents/describe", () => ({
|
||||
runDocumentSummary: vi.fn(),
|
||||
transcribeDocument: vi.fn(),
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { runDocumentSummary, transcribeDocument } from "@/lib/documents/describe";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
const req = (id: string, mode?: string) =>
|
||||
new NextRequest(
|
||||
new URL(
|
||||
`http://localhost/api/documents/inbound/${id}/summary${mode ? `?mode=${mode}` : ""}`,
|
||||
),
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
function assertNoPersistence(): void {
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.create).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
contentCodec: "binary2",
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
it("mode=summary returns { summary } and persists only budget + audit", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
} as never);
|
||||
|
||||
const res = await POST(req("doc-1", "summary") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ summary: "A blood panel from a lab." });
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"documents.inbound.summary",
|
||||
expect.anything(),
|
||||
);
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("mode=text returns { text } session-only", async () => {
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "raw transcribed body",
|
||||
} as never);
|
||||
|
||||
const res = await POST(req("doc-1", "text") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ text: "raw transcribed body" });
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("422s without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
const res = await POST(req("doc-1", "summary") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
assertNoPersistence();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/**
|
||||
* HKDF subkey derivation (P2-D7). The blind content index's HMAC key is derived
|
||||
* this way; the invariants that matter: deterministic under a fixed active key,
|
||||
* domain-separated by `info`, and following the active key on rotation.
|
||||
*/
|
||||
import { _resetCryptoCacheForTests, deriveSubkey } from "@/lib/crypto";
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function useKey(hexKey: string): void {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", hexKey);
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
beforeEach(() => useKey(KEY_V1));
|
||||
|
||||
describe("deriveSubkey", () => {
|
||||
it("returns 32 bytes", () => {
|
||||
expect(deriveSubkey("healthlog:test").byteLength).toBe(32);
|
||||
});
|
||||
|
||||
it("is deterministic under a fixed active key", () => {
|
||||
expect(deriveSubkey("healthlog:test")).toEqual(deriveSubkey("healthlog:test"));
|
||||
});
|
||||
|
||||
it("is domain-separated by info", () => {
|
||||
expect(deriveSubkey("healthlog:a")).not.toEqual(deriveSubkey("healthlog:b"));
|
||||
});
|
||||
|
||||
it("is never the raw master key", () => {
|
||||
const master = Buffer.from(KEY_V1, "hex");
|
||||
expect(deriveSubkey("healthlog:test").equals(master)).toBe(false);
|
||||
});
|
||||
|
||||
it("follows the active key on rotation", () => {
|
||||
const under1 = deriveSubkey("healthlog:test");
|
||||
useKey(KEY_V2);
|
||||
const under2 = deriveSubkey("healthlog:test");
|
||||
expect(under2).not.toEqual(under1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/**
|
||||
* Blind content-search index (Document vault P2).
|
||||
*
|
||||
* Pins the token scheme's correctness (whole-word match, stopword drop,
|
||||
* de-accent, length gate, cap) AND the security invariant A4: nothing readable
|
||||
* at rest — the stored text column is ciphertext and the token array is opaque
|
||||
* HMAC output, not plaintext.
|
||||
*/
|
||||
|
||||
// The upsert path touches prisma; the pure helpers do not, but the module
|
||||
// imports @/lib/db at load, so mock it.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { documentContentIndex: { upsert: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { _resetCryptoCacheForTests } from "@/lib/crypto";
|
||||
import {
|
||||
CONTENT_TOKENIZER_VERSION,
|
||||
decryptIndexText,
|
||||
encryptIndexText,
|
||||
hashQueryTokens,
|
||||
normaliseIndexText,
|
||||
tokenise,
|
||||
tokeniseAndHash,
|
||||
} from "../content-index";
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function useKey(hexKey: string): void {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", hexKey);
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useKey(KEY_V1);
|
||||
});
|
||||
|
||||
describe("normaliseIndexText", () => {
|
||||
it("lowercases and strips diacritics", () => {
|
||||
expect(normaliseIndexText("Nürnberg ÄÖÜ Café")).toBe("nurnberg aou cafe");
|
||||
});
|
||||
|
||||
it("caps the stored text at the byte budget", () => {
|
||||
const huge = "a".repeat(200_000);
|
||||
expect(normaliseIndexText(huge).length).toBeLessThanOrEqual(64 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokenise", () => {
|
||||
it("keeps content words, drops stopwords and short tokens", () => {
|
||||
const tokens = tokenise("The patient has Diabetes and a high value");
|
||||
expect(tokens).toContain("patient");
|
||||
expect(tokens).toContain("diabetes");
|
||||
expect(tokens).toContain("high");
|
||||
expect(tokens).toContain("value");
|
||||
// stopwords + < 3 chars dropped
|
||||
expect(tokens).not.toContain("the");
|
||||
expect(tokens).not.toContain("and");
|
||||
expect(tokens).not.toContain("has"); // length 3 but stopword
|
||||
});
|
||||
|
||||
it("de-accents so an accented body word matches its plain query", () => {
|
||||
expect(tokenise("Röntgenbefund")).toContain("rontgenbefund");
|
||||
});
|
||||
|
||||
it("dedupes repeated tokens", () => {
|
||||
const tokens = tokenise("cholesterol cholesterol CHOLESTEROL");
|
||||
expect(tokens.filter((t) => t === "cholesterol")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokeniseAndHash (blind index)", () => {
|
||||
it("produces opaque hex tags, never the plaintext token", () => {
|
||||
const hashes = tokeniseAndHash("hemoglobin");
|
||||
expect(hashes).toHaveLength(1);
|
||||
expect(hashes[0]).toMatch(/^[0-9a-f]{16}$/u);
|
||||
expect(hashes[0]).not.toContain("hemoglobin");
|
||||
});
|
||||
|
||||
it("is deterministic under a fixed key", () => {
|
||||
expect(tokeniseAndHash("creatinine")).toEqual(tokeniseAndHash("creatinine"));
|
||||
});
|
||||
|
||||
it("a query word hashes to the same tag as the body word (whole-word hit)", () => {
|
||||
const body = tokeniseAndHash("The full blood count showed leukocytes");
|
||||
const query = hashQueryTokens("leukocytes");
|
||||
expect(query).toHaveLength(1);
|
||||
expect(body).toContain(query[0]);
|
||||
});
|
||||
|
||||
it("a substring is NOT a whole-word hit (honest limit)", () => {
|
||||
const body = tokeniseAndHash("leukocytes");
|
||||
// "leuko" is a prefix, not a whole token → different tag, no overlap.
|
||||
const query = hashQueryTokens("leuko");
|
||||
expect(body.some((h) => query.includes(h))).toBe(false);
|
||||
});
|
||||
|
||||
it("changes when the master key rotates (subkey follows the active key)", () => {
|
||||
const under1 = tokeniseAndHash("glucose");
|
||||
useKey(KEY_V2);
|
||||
const under2 = tokeniseAndHash("glucose");
|
||||
expect(under2).not.toEqual(under1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptIndexText (no plaintext at rest — A4)", () => {
|
||||
it("round-trips and never stores the plaintext in the ciphertext bytes", () => {
|
||||
const text = "confidential diagnosis text";
|
||||
const encrypted = encryptIndexText(text);
|
||||
expect(Buffer.from(encrypted).toString("utf8")).not.toContain(text);
|
||||
expect(decryptIndexText(encrypted)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokenizer version", () => {
|
||||
it("is exported for re-index detection", () => {
|
||||
expect(CONTENT_TOKENIZER_VERSION).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Content-index backfill (Document vault P2). Pins: no provider / no consent →
|
||||
* clean no-op; indexes not-yet-indexed docs; stops when the budget is reached
|
||||
* (resumable); skips are bounded by the MIME-filtered candidate set.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
vi.mock("@/lib/documents/describe", () => ({
|
||||
transcribeDocument: vi.fn().mockResolvedValue({ text: "glucose 90" }),
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
|
||||
import { runContentIndexBackfillForUser } from "../document-content-index-backfill";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { assertConsentForChain, ConsentRequiredError } from "@/lib/ai/consent-guard";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
|
||||
const PICK = {
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
};
|
||||
|
||||
const doc = (id: string) => ({
|
||||
id,
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
contentCodec: "binary2",
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 1 } as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockImplementation(
|
||||
async ({ where }: never) => doc((where as { id: string }).id) as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("runContentIndexBackfillForUser", () => {
|
||||
it("no-ops with no provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
expect(result).toEqual({ indexed: 0, reason: "no-provider" });
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when consent is missing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertConsentForChain).mockRejectedValueOnce(
|
||||
new ConsentRequiredError("insights" as never),
|
||||
);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
expect(result).toEqual({ indexed: 0, reason: "no-consent" });
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("indexes the not-yet-indexed documents", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
// One short page (< PAGE_SIZE) — the walk breaks after it, so a single
|
||||
// findMany return is enough (a trailing once would leak to the next test).
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "d1" },
|
||||
{ id: "d2" },
|
||||
] as never);
|
||||
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
expect(result).toEqual({ indexed: 2, reason: "ok" });
|
||||
expect(upsertContentIndex).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops when the daily budget is reached (resumable)", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1" },
|
||||
{ id: "d2" },
|
||||
] as never);
|
||||
// First doc gets budget, second is denied → stop before indexing it.
|
||||
vi.mocked(reserveBudget)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ allowed: true, reserved: 1 } as never)
|
||||
.mockResolvedValueOnce({ allowed: false, reserved: 0 } as never)
|
||||
.mockResolvedValue({ allowed: true, reserved: 1 } as never);
|
||||
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
expect(result).toEqual({ indexed: 1, reason: "budget-reached" });
|
||||
expect(upsertContentIndex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user