diff --git a/docs/RETENTION.md b/docs/RETENTION.md index 9963e1d..b878420 100644 --- a/docs/RETENTION.md +++ b/docs/RETENTION.md @@ -64,7 +64,7 @@ creator (`created_by_principal_id` — the document's first version for independent of any share grant. A peer who can search a shared document gets 403 on `forget`/`purge`/`retention-class` for it. See `src/services/retention-ownership.ts` and the ownership tests in -`src/memory.test.ts` / `src/routes/routes.test.ts`. +`tests/grants.test.ts` / `tests/caller-resolver.test.ts`. **`sweepEphemeral` stays off the HTTP surface.** It is a maintenance sweep — "deprecate every ephemeral version past its TTL for this tenant" — not diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts deleted file mode 100644 index 57a0567..0000000 --- a/src/db/schema.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * CL-5233: all engine tables live under the memory Postgres schema. Renamed from `knowledge` to `memory` for CL-6009. - */ -import { describe, expect, it } from "bun:test"; -import { getTableName } from "drizzle-orm"; -import { - MEMORY_SCHEMA, - memoryChunk, - memoryDocument, - memoryEdge, - memoryEmbedModel, - memoryEntity, - memorySchema, - memoryVersion, - rawCapture, - transformConfig, - transformRun, -} from "./schema.js"; - -const TABLES = [ - memoryDocument, - memoryVersion, - memoryChunk, - memoryEntity, - memoryEdge, - rawCapture, - memoryEmbedModel, - transformConfig, - transformRun, -] as const; - -describe("memory Postgres schema qualification (CL-5233)", () => { - it("exports MEMORY_SCHEMA = memory", () => { - expect(MEMORY_SCHEMA).toBe("memory"); - expect(memorySchema.schemaName).toBe("memory"); - }); - - it("every table is registered under the memory schema", () => { - for (const table of TABLES) { - // drizzle Table internal schema key - const schemaName = (table as unknown as { [key: symbol]: unknown })[ - Symbol.for("drizzle:Schema") - ]; - expect(schemaName).toBe("memory"); - // bare names drop the redundant memory_ prefix - expect(getTableName(table)).not.toMatch(/^memory_/); - } - }); - - it("maps legacy memory_* names to short table names", () => { - expect(getTableName(memoryDocument)).toBe("document"); - expect(getTableName(memoryVersion)).toBe("version"); - expect(getTableName(memoryChunk)).toBe("chunk"); - expect(getTableName(memoryEntity)).toBe("entity"); - expect(getTableName(memoryEdge)).toBe("edge"); - expect(getTableName(memoryEmbedModel)).toBe("embed_model"); - expect(getTableName(rawCapture)).toBe("raw_capture"); - expect(getTableName(transformConfig)).toBe("transform_config"); - expect(getTableName(transformRun)).toBe("transform_run"); - }); -}); diff --git a/src/distiller/workflow.test.ts b/src/distiller/workflow.test.ts deleted file mode 100644 index 6064a4c..0000000 --- a/src/distiller/workflow.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "bun:test"; - -import { - RESIDENT_DISTILLER_AGENT_ID, - RESIDENT_DISTILLER_WORKFLOW_ID, -} from "./constants.js"; -import { SIDECAR_BUNDLE_ID } from "../sidecar-bundle.js"; -import { createResidentDistiller } from "./workflow.js"; - -describe("createResidentDistiller", () => { - it("returns a mail-triggered workflow with memory tools on the agent", () => { - const { workflow, agent, generatorAgentId } = createResidentDistiller({ - mailTo: "resident-distiller@tenant.example.com", - inference: { - sources: [{ provider: "openai", model: "gpt-4.1-mini" }], - }, - }); - - expect(generatorAgentId).toBe(RESIDENT_DISTILLER_AGENT_ID); - expect(workflow.id).toBe(RESIDENT_DISTILLER_WORKFLOW_ID); - expect(workflow.triggers).toEqual([ - { type: "mail", to: "resident-distiller@tenant.example.com" }, - ]); - expect(agent.id).toBe(RESIDENT_DISTILLER_AGENT_ID); - // One factory: the memory sidecar bundle carries every memory tool. - expect(agent.toolFactories.map((factory) => factory.id)).toEqual([ - SIDECAR_BUNDLE_ID, - ]); - expect(agent.systemPrompt).toContain("memory_feed"); - expect(agent.systemPrompt).toContain(RESIDENT_DISTILLER_AGENT_ID); - }); - - it("allows mailTo and id overrides", () => { - const { workflow, generatorAgentId, agent } = createResidentDistiller({ - id: "my-distiller", - agentId: "my-agent", - mailTo: "my-agent@acme.example.com", - inference: { - sources: [{ provider: "openai", model: "gpt-4.1-mini" }], - }, - }); - expect(workflow.id).toBe("my-distiller"); - expect(generatorAgentId).toBe("my-agent"); - expect(workflow.triggers[0]).toEqual({ - type: "mail", - to: "my-agent@acme.example.com", - }); - expect(agent.systemPrompt).toContain("my-agent"); - expect(agent.systemPrompt).not.toContain(RESIDENT_DISTILLER_AGENT_ID); - }); - - it("does not carry a cron/schedule field in its public options", () => { - const opts: import("./workflow.js").CreateResidentDistillerOpts = { - mailTo: "resident-distiller@tenant.example.com", - inference: { sources: [{ provider: "openai", model: "gpt-4.1-mini" }] }, - }; - expect("cron" in opts).toBe(false); - }); -}); diff --git a/src/grant-requirements.test.ts b/src/grant-requirements.test.ts deleted file mode 100644 index 5427dd9..0000000 --- a/src/grant-requirements.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - capabilityIdsForSurface, - MEMORY_CAPABILITY_IDS, - MEMORY_GRANT_REQUIREMENTS, -} from "./grant-requirements.js"; - -describe("MEMORY_GRANT_REQUIREMENTS", () => { - test("covers add, search, forget, and purge on memory resource", () => { - expect(MEMORY_GRANT_REQUIREMENTS.map((r) => r.action).sort()).toEqual([ - "add", - "forget", - "purge", - "search", - ]); - for (const r of MEMORY_GRANT_REQUIREMENTS) { - expect(r.resource).toBe("memory"); - } - }); - - test("add/search hint tenant-wide and reach tools + distiller + routes", () => { - for (const action of ["add", "search"]) { - const r = MEMORY_GRANT_REQUIREMENTS.find((x) => x.action === action)!; - expect(r.installHint).toBe("tenant"); - expect(r.surfaces).toContain("tools"); - expect(r.surfaces).toContain("distiller"); - expect(r.surfaces).toContain("routes"); - } - }); - - test("forget/purge hint creator-scoped and are routes-only", () => { - for (const action of ["forget", "purge"]) { - const r = MEMORY_GRANT_REQUIREMENTS.find((x) => x.action === action)!; - expect(r.installHint).toBe("creator"); - expect(r.surfaces).toEqual(["routes"]); - } - }); - - test("installHint is advisory only — the requirement shape carries no enforcement field", () => { - // The actual ownership check lives in services/retention-ownership.ts, - // wired imperatively into memory.ts, entirely independent of this hint. - // This test exists so a future reader who tightens grant-requirements.ts - // notices this comment rather than assuming installHint is load-bearing. - for (const r of MEMORY_GRANT_REQUIREMENTS) { - expect(Object.keys(r).sort()).toEqual([ - "action", - "installHint", - "resource", - "surfaces", - ]); - } - }); - - test("capability ids are resource:action", () => { - expect([...MEMORY_CAPABILITY_IDS].sort()).toEqual([ - "memory:add", - "memory:forget", - "memory:purge", - "memory:search", - ]); - }); - - test("capabilityIdsForSurface excludes routes-only actions from distiller/tools", () => { - expect(capabilityIdsForSurface("distiller").sort()).toEqual([ - "memory:add", - "memory:search", - ]); - expect(capabilityIdsForSurface("tools").sort()).toEqual([ - "memory:add", - "memory:search", - ]); - expect(capabilityIdsForSurface("routes").sort()).toEqual([ - "memory:add", - "memory:forget", - "memory:purge", - "memory:search", - ]); - }); -}); diff --git a/src/log.test.ts b/src/log.test.ts deleted file mode 100644 index cc165cb..0000000 --- a/src/log.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { formatCaughtError } from "./log.js"; -import { RerankHttpError } from "./core/rerank-client.js"; - -describe("formatCaughtError", () => { - it("returns an Error's message, unchanged", () => { - expect(formatCaughtError(new Error("boom"))).toBe("boom"); - }); - - it("carries a RerankHttpError's full diagnostic message (status, url, body) through untouched — this is the exact detail CL-4599 was dropping at render time", () => { - const err = new RerankHttpError( - 413, - '{"error":"Input validation error: `inputs` must have less than 512 tokens. Given: 855"}', - "https://rerank.example/rerank", - ); - const message = formatCaughtError(err); - expect(message).toContain("413"); - expect(message).toContain("https://rerank.example/rerank"); - expect(message).toContain("must have less than 512 tokens"); - }); - - it("stringifies a non-Error thrown value rather than dropping it", () => { - expect(formatCaughtError("plain string throw")).toBe("plain string throw"); - expect(formatCaughtError(42)).toBe("42"); - }); -}); diff --git a/src/memory.test.ts b/src/memory.test.ts deleted file mode 100644 index 3bf4ac9..0000000 --- a/src/memory.test.ts +++ /dev/null @@ -1,982 +0,0 @@ -/** - * Plane construction, grant-tag ACL wiring, and surface coverage for memory.ts. - * - * - Construction: rerank maxDocChars validation runs in createMemory. - * - Search wiring: grant-tag post-filter (creator-only without grants). - * - add(): documentId return, content/file XOR, share → access tags. - * - search/list: limit bounds, evidence default omit. - */ -import { - afterAll, - beforeAll, - describe, - expect, - it, - mock, -} from "bun:test"; -import { createInMemoryGrantStore } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; - -import { RerankConfigError } from "./core/rerank-client.js"; -import type { SearchHit } from "./core/schemas/search.js"; -import { - createMemory, - MemoryError, - type MemoryAddParams, - type SearchItem, - type TextExtractor, -} from "./memory.js"; -import type { MemoryConfig } from "./mount-config.js"; -import * as realDb from "./db/client.js"; -import * as realSearch from "./services/search.js"; -import * as realCapture from "./services/capture.js"; -import * as realRetention from "./services/retention.js"; - -// Bun's module registry is process-global and `mock.module()` rewrites live -// bindings in place — including these `realX` namespaces — with no unmock -// API (`mock.restore()` leaves module mocks installed, and re-mocking with -// the live namespace just reinstalls the mock). A spread snapshot taken here, -// before any mock runs, is immune to that rewrite and is what the afterAll -// hooks below reinstall so later test files import the real modules. -const pristineDb = { ...realDb }; -const pristineSearch = { ...realSearch }; -const pristineCapture = { ...realCapture }; -const pristineRetention = { ...realRetention }; -import type { HybridSearchResult } from "./services/search.js"; - -const PRINCIPAL = "p1"; -const TENANT = "t1"; - -type DocAclRow = { - id: string; - access_tags: string[] | null; - created_by: string | null; -}; - -/** Satisfies createFtsVerification → createRawSqlClient(sql).unsafe on the find path. */ -const ENGLISH_FTS_EXPR = "to_tsvector('english'::regconfig, text)"; - -function grant(action: string): GrantRule { - return { - id: `g-${action}`, - resource: "memory", - action, - effect: "allow", - origin: "role", - conditions: null, - expiresAt: null, - roleId: null, - principalId: PRINCIPAL, - }; -} - -function hit(overrides: Partial | string = {}): SearchHit { - if (typeof overrides === "string") { - const documentId = overrides; - return { - chunk_id: `chunk-${documentId}`, - document_id: documentId, - version: 1, - version_id: "v1", - status: "active", - score: 0.9, - title: `Doc ${documentId}`, - snippet: "snippet", - kind: "note", - created_by_kind: "human", - citation: { - adapter: "mcp", - external_ref: `ref-${documentId}`, - open: { type: "doc", id: documentId }, - }, - entity_ids: [], - channels_matched: ["lexical"], - }; - } - return { - chunk_id: "chunk-1", - document_id: "doc-1", - version: 1, - version_id: "v1", - status: "active", - score: 0.9, - title: "Doc One", - snippet: "the relevant snippet", - kind: "note", - created_by_kind: "human", - citation: { - adapter: "mcp", - external_ref: "ref-1", - open: { type: "doc", id: "doc-1" }, - }, - entity_ids: [], - channels_matched: ["lexical"], - ...overrides, - }; -} - -const wiringConfig: MemoryConfig = { - memory: { - databaseUrl: "postgres://localhost:5432/nonexistent-test-db", - dbPoolMax: 1, - ftsLanguage: "english", - embed: { - baseUrl: "http://embed", - model: "m", - apiStyle: "openai", - apiKey: undefined, - timeoutMs: undefined, - }, - rerank: { - baseUrl: undefined, - model: undefined, - apiKey: undefined, - maxDocChars: undefined, - timeoutMs: undefined, - }, - }, -}; - -function ftsUnsafe(sqlText: string): Promise>> { - if (sqlText.includes("pg_ts_config")) { - return Promise.resolve([{ ok: 1 }]); - } - return Promise.resolve([{ expr: ENGLISH_FTS_EXPR }]); -} - -function baseConfig( - rerank: MemoryConfig["memory"]["rerank"], -): MemoryConfig { - return { - memory: { - // Validation runs before createDb — a bad URL is fine as long as we throw - // first and never open a connection. - databaseUrl: "postgres://localhost:5432/nonexistent-test-db", - dbPoolMax: 1, - ftsLanguage: "english", - embed: { - baseUrl: "http://embed", - model: "m", - apiStyle: "openai", - apiKey: undefined, - timeoutMs: undefined, - }, - rerank, - }, - }; -} - -describe("createMemory — construction validation", () => { - it("throws RerankConfigError when maxDocChars overflows a known TEI model", () => { - // Proves validateRerankConfig runs inside createMemory (not only - // createMemory): a standalone plane with a bad override must fail - // construction, not silently degrade on every later find. - expect(() => - createMemory({ - config: baseConfig({ - baseUrl: "https://tei.example.com", - model: "bge-reranker-base", - apiKey: undefined, - maxDocChars: 5_000, - timeoutMs: undefined, - }), - }), - ).toThrow(RerankConfigError); - }); -}); - -// CL-6287 review: a consumer (settings page, health check) must be able to -// learn recall is lexical-only WITHOUT issuing a search first. -describe("createMemory — capabilities.embeddingsConfigured (CL-6287)", () => { - it("reports true when EngineConfig.embed is configured", async () => { - const plane = createMemory({ - config: baseConfig({ - baseUrl: undefined, - model: undefined, - apiKey: undefined, - maxDocChars: undefined, - timeoutMs: undefined, - }), - }); - expect(plane.capabilities.embeddingsConfigured).toBe(true); - await plane.close(); - }); - - it("reports false when EngineConfig.embed is absent (lexical-only)", async () => { - const config: MemoryConfig = { - memory: { - databaseUrl: "postgres://localhost:5432/nonexistent-test-db", - dbPoolMax: 1, - ftsLanguage: "english", - rerank: { - baseUrl: undefined, - model: undefined, - apiKey: undefined, - maxDocChars: undefined, - timeoutMs: undefined, - }, - }, - }; - const plane = createMemory({ config }); - expect(plane.capabilities.embeddingsConfigured).toBe(false); - await plane.close(); - }); - - it("defaults to true for a custom DocumentStore that doesn't report its own capabilities", async () => { - const plane = createMemory({ - documentStore: { - add: async () => ({ documentId: "d1", versionId: "v1" }), - search: async () => ({ items: [] }), - list: async () => [], - close: async () => {}, - }, - }); - expect(plane.capabilities.embeddingsConfigured).toBe(true); - await plane.close(); - }); -}); - -describe("createMemory.find — grant-tag post-filter wiring", () => { - const hybridSearch = mock((): Promise => - Promise.resolve({ - hits: [hit("d-other"), hit("d-mine")], - evidence: "strong", - }), - ); - - const sql = Object.assign( - mock((): Promise => - Promise.resolve([ - { - id: "d-other", - access_tags: ["memory.owner:other"], - created_by: "other", - }, - { - id: "d-mine", - access_tags: [`memory.owner:${PRINCIPAL}`], - created_by: PRINCIPAL, - }, - ]), - ), - { - end: mock(() => Promise.resolve()), - unsafe: mock((sqlText: string) => ftsUnsafe(sqlText)), - }, - ); - - beforeAll(() => { - mock.module("./db/client.js", () => ({ - ...realDb, - createDb: () => ({ db: {}, sql }), - })); - mock.module("./services/search.js", () => ({ - ...realSearch, - hybridSearch, - })); - }); - - afterAll(() => { - mock.module("./db/client.js", () => pristineDb); - mock.module("./services/search.js", () => pristineSearch); - }); - - it("keeps creator docs and drops others when grants are absent", async () => { - // Without grants the engine store is creator-only (safe default). - hybridSearch.mockClear(); - hybridSearch.mockImplementation(() => - Promise.resolve({ - hits: [hit("d-other"), hit("d-mine")], - evidence: "strong" as const, - }), - ); - sql.mockClear(); - sql.mockImplementation(() => - Promise.resolve([ - { - id: "d-other", - access_tags: ["memory.owner:other"], - created_by: "other", - }, - { - id: "d-mine", - access_tags: [`memory.owner:${PRINCIPAL}`], - created_by: PRINCIPAL, - }, - ]), - ); - - const { createMemory: makePlane } = await import( - `./memory.ts?wiring-blocked=${Date.now()}` - ); - const plane = makePlane({ config: wiringConfig }); - - const result = await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - includeEvidence: true, - }); - - expect(hybridSearch).toHaveBeenCalled(); - expect(result.items.map((i: SearchItem) => i.documentId)).toEqual([ - "d-mine", - ]); - expect(result.evidence).toBe("strong"); - - await plane.close(); - }); - - it("threads kinds and entityIds through to hybridSearch", async () => { - // Regression guard for CL-5021: the plane must pass kinds/entityIds - // straight through to the service that already supports them, not - // silently drop them. - hybridSearch.mockClear(); - hybridSearch.mockImplementation(() => - Promise.resolve({ hits: [], evidence: "none" as const }), - ); - sql.mockClear(); - - const { createMemory: makePlane } = await import( - `./memory.ts?wiring-kinds=${Date.now()}` - ); - const plane = makePlane({ config: wiringConfig }); - - await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - kinds: ["artifact", "task"], - entityIds: ["e1"], - }); - - expect(hybridSearch).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - kinds: ["artifact", "task"], - entityIds: ["e1"], - }), - ); - - await plane.close(); - }); - - it("withholds a hit whose row did not come back (fail-closed)", async () => { - hybridSearch.mockClear(); - hybridSearch.mockImplementation(() => - Promise.resolve({ - hits: [hit("d-missing")], - evidence: "strong" as const, - }), - ); - sql.mockClear(); - sql.mockImplementation(() => Promise.resolve([])); - - const { createMemory: makePlane } = await import( - `./memory.ts?wiring-unreadable=${Date.now()}` - ); - const plane = makePlane({ config: wiringConfig }); - - const result = await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - includeEvidence: true, - }); - - expect(result.items).toEqual([]); - expect(result.evidence).toBe("none"); - - await plane.close(); - }); - - it("omits evidence by default and includes it when includeEvidence is true", async () => { - hybridSearch.mockClear(); - hybridSearch.mockImplementation(() => - Promise.resolve({ - hits: [hit("d-open")], - evidence: "strong" as const, - degraded: ["dense_unavailable" as const], - }), - ); - sql.mockClear(); - sql.mockImplementation(() => - Promise.resolve([ - { - id: "d-open", - access_tags: [`memory.owner:${PRINCIPAL}`], - created_by: PRINCIPAL, - }, - ]), - ); - - const { createMemory: makePlane } = await import( - `./memory.ts?wiring-evidence=${Date.now()}` - ); - const plane = makePlane({ config: wiringConfig }); - - - const without = await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - }); - expect(without.items).toHaveLength(1); - expect(without.evidence).toBeUndefined(); - expect(without.degraded).toBeUndefined(); - - const withEv = await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - includeEvidence: true, - }); - expect(withEv.items).toHaveLength(1); - expect(withEv.evidence).toBe("strong"); - expect(withEv.degraded).toEqual(["dense_unavailable"]); - - await plane.close(); - }); -}); - -describe("search/list — limit bounds", () => { - // These throw before any DB work, so a nonexistent URL is fine. - it("find rejects limit below 1", async () => { - const plane = createMemory({ config: wiringConfig }); - try { - await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - limit: 0, - }); - throw new Error("expected find() to reject"); - } catch (err) { - expect(err).toBeInstanceOf(MemoryError); - expect((err as MemoryError).status).toBe(400); - expect((err as MemoryError).message).toContain("limit"); - } - }); - - it("find rejects limit above 50", async () => { - const plane = createMemory({ config: wiringConfig }); - try { - await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "q", - limit: 51, - }); - throw new Error("expected find() to reject"); - } catch (err) { - expect(err).toBeInstanceOf(MemoryError); - expect((err as MemoryError).status).toBe(400); - } - }); - - it("recent rejects limit above 100", async () => { - const plane = createMemory({ config: wiringConfig }); - try { - await plane.list({ - tenantId: TENANT, - principalId: PRINCIPAL, - limit: 101, - }); - throw new Error("expected recent() to reject"); - } catch (err) { - expect(err).toBeInstanceOf(MemoryError); - expect((err as MemoryError).status).toBe(400); - } - }); - - it("recent rejects limit below 1", async () => { - const plane = createMemory({ config: wiringConfig }); - try { - await plane.list({ - tenantId: TENANT, - principalId: PRINCIPAL, - limit: 0, - }); - throw new Error("expected recent() to reject"); - } catch (err) { - expect(err).toBeInstanceOf(MemoryError); - expect((err as MemoryError).status).toBe(400); - } - }); -}); - -describe("add() — documentId, content/file XOR, share", () => { - type CaptureDocResult = { - status: "captured" | "noop"; - documentId: string; - versionId: string; - chunks: number; - }; - const captureDocument = mock( - (): Promise => - Promise.resolve({ - status: "captured", - documentId: "kdoc_test_1", - versionId: "kver_1", - chunks: 1, - }), - ); - - const sql = Object.assign(mock(() => Promise.resolve([])), { - end: mock(() => Promise.resolve()), - unsafe: mock((sqlText: string) => ftsUnsafe(sqlText)), - }); - - beforeAll(() => { - mock.module("./db/client.js", () => ({ - ...realDb, - createDb: () => ({ db: {}, sql }), - })); - mock.module("./services/capture.js", () => ({ - ...realCapture, - captureDocument, - })); - }); - - afterAll(() => { - mock.module("./db/client.js", () => pristineDb); - mock.module("./services/capture.js", () => pristineCapture); - }); - -async function freshPlane(opts?: { - textExtractor?: TextExtractor; - }) { - const { createMemory: makePlane } = await import( - `./memory.ts?add-${Date.now()}-${Math.random()}` - ); - return makePlane({ config: wiringConfig, ...(opts ?? {}) }); - - } - - /** Dynamic re-import yields a distinct MemoryError class; match by shape. */ - function expectMemoryError400(err: unknown, messagePart: string) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("MemoryError"); - expect((err as { status: number }).status).toBe(400); - expect((err as Error).message).toContain(messagePart); - } - - it("returns documentId from captureDocument (captured status)", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "captured" as const, - documentId: "kdoc_captured", - versionId: "kver_1", - chunks: 1, - }), - ); - const plane = await freshPlane(); - const result = await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "T", text: "body" }, - }); - expect(result).toEqual({ documentId: "kdoc_captured", versionId: "kver_1" }); - await plane.close(); - }); - - it("returns documentId on noop status too", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "noop" as const, - documentId: "kdoc_noop", - versionId: "kver_1", - chunks: 0, - }), - ); - const plane = await freshPlane(); - const result = await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "T", text: "body" }, - }); - expect(result).toEqual({ documentId: "kdoc_noop", versionId: "kver_1" }); - await plane.close(); - }); - - it("rejects when neither content nor file is provided", async () => { - const plane = await freshPlane(); - try { - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - } as MemoryAddParams); - throw new Error("expected add() to reject"); - } catch (err) { - expectMemoryError400(err, "content or file"); - } - await plane.close(); - }); - - it("rejects when both content and file are provided", async () => { - const plane = await freshPlane(); - try { - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "T", text: "body" }, - file: { bytes: new Uint8Array([1]) }, - }); - throw new Error("expected add() to reject"); - } catch (err) { - expectMemoryError400(err, "content or file"); - } - await plane.close(); - }); - - it("rejects file without a textExtractor", async () => { - const plane = await freshPlane(); - try { - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - file: { bytes: new Uint8Array([1]), filename: "a.pdf" }, - }); - throw new Error("expected add() to reject"); - } catch (err) { - expectMemoryError400(err, "textExtractor"); - } - await plane.close(); - }); - - it("extracts text via textExtractor when file is provided", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "captured" as const, - documentId: "kdoc_file", - versionId: "kver_1", - chunks: 1, - }), - ); - const textExtractor: TextExtractor = { - extract: mock(() => - Promise.resolve({ text: "extracted body", title: "From Extractor" }), - ), - }; - const plane = await freshPlane({ textExtractor }); - const result = await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - file: { - bytes: new Uint8Array([1, 2, 3]), - mimeType: "application/pdf", - filename: "note.pdf", - }, - }); - expect(result.documentId).toBe("kdoc_file"); - expect(textExtractor.extract).toHaveBeenCalled(); - expect(captureDocument).toHaveBeenCalled(); - const call = captureDocument.mock.calls[0] as unknown as [ - unknown, - { document: { title: string; chunks: { text: string }[] } }, - ]; - expect(call[1].document.title).toBe("From Extractor"); - expect(call[1].document.chunks[0]?.text).toBe("extracted body"); - await plane.close(); - }); - - it("maps share.tenant to tenant access tag", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "captured" as const, - documentId: "kdoc_share", - versionId: "kver_1", - chunks: 1, - }), - ); - const plane = await freshPlane(); - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "Team note", text: "shared" }, - share: { tenant: true }, - }); - const call = captureDocument.mock.calls[0] as unknown as [ - unknown, - { - document: { - accessTags: string[]; - }; - }, - ]; - expect(call[1].document.accessTags).toContain( - `memory.owner:${PRINCIPAL}`, - ); - expect(call[1].document.accessTags).toContain( - `memory.tenant:${TENANT}`, - ); - await plane.close(); - }); - - it("maps share.principals to peer owner tags", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "captured" as const, - documentId: "kdoc_principals", - versionId: "kver_1", - chunks: 1, - }), - ); - const plane = await freshPlane(); - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "Shared", text: "body" }, - share: { principals: ["alice", "bob"] }, - }); - const call = captureDocument.mock.calls[0] as unknown as [ - unknown, - { - document: { - accessTags: string[]; - }; - }, - ]; - expect(call[1].document.accessTags).toContain( - `memory.owner:${PRINCIPAL}`, - ); - expect(call[1].document.accessTags).toContain("memory.owner:alice"); - expect(call[1].document.accessTags).toContain("memory.owner:bob"); - await plane.close(); - }); - - it("defaults to owner-only access tags", async () => { - captureDocument.mockClear(); - captureDocument.mockImplementation(() => - Promise.resolve({ - status: "captured" as const, - documentId: "kdoc_default", - versionId: "kver_1", - chunks: 1, - }), - ); - const plane = await freshPlane(); - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "T", text: "body" }, - }); - const call = captureDocument.mock.calls[0] as unknown as [ - unknown, - { - document: { - accessTags: string[]; - }; - }, - ]; - expect(call[1].document.accessTags).toEqual([ - `memory.owner:${PRINCIPAL}`, - ]); - await plane.close(); - }); -}); - -describe("retention writes — ownership gate (CL-6288)", () => { - const OWNER = "alice"; - const OTHER = "mallory"; - - const tombstoneDocument = mock(() => Promise.resolve({ versions: 1 })); - const hardDeleteDocument = mock(() => Promise.resolve({ deleted: true })); - const setRetentionClass = mock(() => - Promise.resolve({ - versionId: "ver-1", - documentId: "doc-1", - status: "active", - }), - ); - - /** - * Only "doc-1" / "ver-1" exist, created by OWNER — everything else is a - * miss. resolveDocumentOwner and resolveVersionOwner both just need "a - * creator row" here (their distinct WHERE clauses are unit-tested with - * real scoping in retention-ownership.test.ts) so this fake does not - * branch on query text — a branch whose arms return the same row proves - * nothing and only invites the reader to assume a distinction that isn't - * there. - */ - const sql = Object.assign( - mock((_strings: TemplateStringsArray, ...values: unknown[]) => { - const isMissing = - values.includes("doc-missing") || values.includes("ver-missing"); - if (isMissing) return Promise.resolve([]); - return Promise.resolve([{ created_by_principal_id: OWNER }]); - }), - { - end: mock(() => Promise.resolve()), - unsafe: mock((sqlText: string) => ftsUnsafe(sqlText)), - }, - ); - - beforeAll(() => { - mock.module("./db/client.js", () => ({ - ...realDb, - createDb: () => ({ db: {}, sql }), - })); - mock.module("./services/retention.js", () => ({ - ...realRetention, - tombstoneDocument, - hardDeleteDocument, - setRetentionClass, - })); - }); - - afterAll(() => { - mock.module("./db/client.js", () => pristineDb); - mock.module("./services/retention.js", () => pristineRetention); - }); - - async function freshPlane() { - const { createMemory: makePlane } = await import( - `./memory.ts?retention-${Date.now()}-${Math.random()}` - ); - return makePlane({ config: wiringConfig }); - } - - function expectMemoryError(err: unknown, status: number, messagePart: string) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("MemoryError"); - expect((err as { status: number }).status).toBe(status); - expect((err as Error).message).toContain(messagePart); - } - - it("tombstoneDocument succeeds for the creator", async () => { - tombstoneDocument.mockClear(); - const plane = await freshPlane(); - const result = await plane.tombstoneDocument({ - tenantId: TENANT, - principalId: OWNER, - documentId: "doc-1", - }); - expect(result).toEqual({ versions: 1 }); - expect(tombstoneDocument).toHaveBeenCalled(); - await plane.close(); - }); - - it("tombstoneDocument is refused for a non-creator even with document visibility", async () => { - tombstoneDocument.mockClear(); - const plane = await freshPlane(); - try { - await plane.tombstoneDocument({ - tenantId: TENANT, - principalId: OTHER, - documentId: "doc-1", - }); - throw new Error("expected tombstoneDocument to reject"); - } catch (err) { - expectMemoryError(err, 403, "creator"); - } - expect(tombstoneDocument).not.toHaveBeenCalled(); - await plane.close(); - }); - - it("tombstoneDocument 404s for an unknown document", async () => { - tombstoneDocument.mockClear(); - const plane = await freshPlane(); - try { - await plane.tombstoneDocument({ - tenantId: TENANT, - principalId: OWNER, - documentId: "doc-missing", - }); - throw new Error("expected tombstoneDocument to reject"); - } catch (err) { - expectMemoryError(err, 404, "not found"); - } - expect(tombstoneDocument).not.toHaveBeenCalled(); - await plane.close(); - }); - - it("hardDeleteDocument succeeds for the creator", async () => { - hardDeleteDocument.mockClear(); - const plane = await freshPlane(); - const result = await plane.hardDeleteDocument({ - tenantId: TENANT, - principalId: OWNER, - documentId: "doc-1", - }); - expect(result).toEqual({ deleted: true }); - expect(hardDeleteDocument).toHaveBeenCalled(); - await plane.close(); - }); - - it("hardDeleteDocument is refused for a non-creator even with document visibility", async () => { - hardDeleteDocument.mockClear(); - const plane = await freshPlane(); - try { - await plane.hardDeleteDocument({ - tenantId: TENANT, - principalId: OTHER, - documentId: "doc-1", - }); - throw new Error("expected hardDeleteDocument to reject"); - } catch (err) { - expectMemoryError(err, 403, "creator"); - } - expect(hardDeleteDocument).not.toHaveBeenCalled(); - await plane.close(); - }); - - it("setRetentionClass succeeds for the version's creator", async () => { - setRetentionClass.mockClear(); - const plane = await freshPlane(); - const result = await plane.setRetentionClass({ - tenantId: TENANT, - principalId: OWNER, - versionId: "ver-1", - retentionClass: "durable", - }); - expect(result).toEqual({ - versionId: "ver-1", - documentId: "doc-1", - status: "active", - }); - expect(setRetentionClass).toHaveBeenCalled(); - await plane.close(); - }); - - it("setRetentionClass is refused for a non-creator", async () => { - setRetentionClass.mockClear(); - const plane = await freshPlane(); - try { - await plane.setRetentionClass({ - tenantId: TENANT, - principalId: OTHER, - versionId: "ver-1", - retentionClass: "durable", - }); - throw new Error("expected setRetentionClass to reject"); - } catch (err) { - expectMemoryError(err, 403, "creator"); - } - expect(setRetentionClass).not.toHaveBeenCalled(); - await plane.close(); - }); - - it("setRetentionClass 404s for an unknown version", async () => { - setRetentionClass.mockClear(); - const plane = await freshPlane(); - try { - await plane.setRetentionClass({ - tenantId: TENANT, - principalId: OWNER, - versionId: "ver-missing", - retentionClass: "durable", - }); - throw new Error("expected setRetentionClass to reject"); - } catch (err) { - expectMemoryError(err, 404, "not found"); - } - expect(setRetentionClass).not.toHaveBeenCalled(); - await plane.close(); - }); -}); diff --git a/src/ports/memory-plane.test.ts b/src/ports/memory-plane.test.ts deleted file mode 100644 index 734f5a3..0000000 --- a/src/ports/memory-plane.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Product path: DocumentStore-backed plane (add / search / list). - * MemoryProvider / ask / remember / recall are not on the product surface. - */ -import { describe, expect, it } from "bun:test"; -import { - createInMemoryGrantStore, - type GrantRule, -} from "@intx/authz"; - -import { createMemory } from "../memory.js"; -import { createFakeDocumentStore } from "./fakes.js"; - -const TENANT = "t_mem"; -const PRINCIPAL = "p_mem"; - -function grant(action: string): GrantRule { - return { - id: `g-${action}`, - resource: "memory", - action, - effect: "allow", - origin: "role", - conditions: null, - expiresAt: null, - roleId: null, - principalId: PRINCIPAL, - }; -} - -describe("DocumentStore product plane", () => { - it("add + search + list round-trip on fakes", async () => { - const plane = createMemory({ - grantStore: createInMemoryGrantStore([ - grant("add"), - grant("search"), - ]), - conditionRegistry: {}, - documentStore: createFakeDocumentStore(), - }); - const { documentId } = await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "doc", text: "document body about widgets" }, - }); - const found = await plane.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "widgets", - includeEvidence: true, - }); - expect(found.items.some((i) => i.documentId === documentId)).toBe(true); - const listed = await plane.list({ - tenantId: TENANT, - principalId: PRINCIPAL, - }); - expect(listed.some((e) => e.title === "doc")).toBe(true); - await plane.close(); - }); - - it("search does not invent hits for other principals without grants", async () => { - const plane = createMemory({ - documentStore: createFakeDocumentStore(), - }); - await plane.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "secret", text: "private note" }, - }); - const other = await plane.search({ - tenantId: TENANT, - principalId: "someone-else", - query: "private", - }); - expect(other.items).toHaveLength(0); - await plane.close(); - }); -}); diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts deleted file mode 100644 index fd3dc16..0000000 --- a/src/routes/routes.test.ts +++ /dev/null @@ -1,1119 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { createInMemoryGrantStore } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; -import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; - -import type { Memory, TimelineEvent } from "../memory.js"; -import { MemoryError } from "../memory.js"; -import { createMemoryRoutes } from "./mount.js"; -import type { RouteDeps } from "./deps.js"; - -function grant(principalId: string, action: string): GrantRule { - return { - id: `g-${principalId}-${action}`, - resource: "memory", - action, - effect: "allow", - origin: "role", - conditions: null, - expiresAt: null, - roleId: null, - principalId, - }; -} - -const PRINCIPAL = "p1"; -const TENANT = "t1"; -const SECRET_TITLE = "Q3 layoffs — draft list"; -const PUBLIC_TITLE = "team standup notes"; - -// "run-principal" mirrors RUN_PRINCIPAL in the callerResolver describe -// blocks below — a document/version created by a resolved machine caller. -const RETENTION_DOCS: Record = { - "doc-mine": { ownerId: PRINCIPAL }, - "doc-alice": { ownerId: "alice" }, - "doc-run-owned": { ownerId: "run-principal" }, -}; - -const RETENTION_VERSIONS: Record< - string, - { ownerId: string; documentId: string } -> = { - "ver-mine": { ownerId: PRINCIPAL, documentId: "doc-mine" }, - "ver-alice": { ownerId: "alice", documentId: "doc-alice" }, - "ver-run-owned": { ownerId: "run-principal", documentId: "doc-run-owned" }, -}; - -function stubPlane(opts?: { - timelineCatalog?: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - >; -}) { - const added: { title: string; tenantId: string; principalId: string }[] = []; - const searched: Array<{ - kinds: string[] | undefined; - entityIds: string[] | undefined; - limit: number | undefined; - }> = []; - const tombstoned: string[] = []; - const purged: string[] = []; - const retentionClassChanges: Array<{ versionId: string; retentionClass: string }> = []; - const catalog = opts?.timelineCatalog ?? []; - const plane: Memory = { - capabilities: { embeddingsConfigured: true }, - search: async (p) => { - searched.push({ kinds: p.kinds, entityIds: p.entityIds, limit: p.limit }); - return { items: [], evidence: "none" }; - }, - add: async (p) => { - added.push({ - title: p.content?.title ?? "", - tenantId: p.tenantId, - principalId: p.principalId, - }); - return { documentId: "doc-stub", versionId: "ver-stub" }; - }, - list: async (p) => { - return catalog - .filter( - (e) => - e.tenantId === p.tenantId && - (e.visibleTo === "tenant" || e.visibleTo.includes(p.principalId)), - ) - .map(({ visibleTo: _v, ...event }) => event); - }, - // Mirrors memory.ts's real ownership gate — a caller who can only see a - // document via a share grant is not its creator and gets refused. - tombstoneDocument: async ({ documentId, principalId }) => { - const doc = RETENTION_DOCS[documentId]; - if (!doc) throw new MemoryError(404, "document not found"); - if (doc.ownerId !== principalId) { - throw new MemoryError(403, "only the document's creator may forget it"); - } - tombstoned.push(documentId); - return { versions: 1 }; - }, - hardDeleteDocument: async ({ documentId, principalId }) => { - const doc = RETENTION_DOCS[documentId]; - if (!doc) throw new MemoryError(404, "document not found"); - if (doc.ownerId !== principalId) { - throw new MemoryError(403, "only the document's creator may purge it"); - } - purged.push(documentId); - return { deleted: true }; - }, - setRetentionClass: async ({ versionId, principalId, retentionClass }) => { - const version = RETENTION_VERSIONS[versionId]; - if (!version) throw new MemoryError(404, "version not found"); - if (version.ownerId !== principalId) { - throw new MemoryError( - 403, - "only the version's creator may change its retention class", - ); - } - retentionClassChanges.push({ versionId, retentionClass }); - return { versionId, documentId: version.documentId, status: "active" }; - }, - close: async () => {}, - }; - return { - plane, - added, - searched, - tombstoned, - purged, - retentionClassChanges, - }; -} - -// One shared browser-session app for the whole file: rebuilding the Hono app -// (route registration + validators) per test is the runtime hotspot, and every -// per-test input the handlers read — grants, catalog, session principal — -// flows through a mutable box the shared app closes over. `buildApp` keeps its -// signature and just resets those boxes, so test bodies are unchanged. -const sharedBrowser = (() => { - const catalog: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - > = []; - const rec = stubPlane({ timelineCatalog: catalog }); - const grantList: GrantRule[] = []; - const grantConfig = { - grantStore: createInMemoryGrantStore(grantList), - conditionRegistry: {}, - }; - const session = { principalId: PRINCIPAL }; - const deps: RouteDeps = { - memory: rec.plane, - requireGrant: createRequireGrant(grantConfig), - }; - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("principal", { - id: session.principalId, - tenantId: TENANT, - kind: "user", - refId: "u1", - status: "active", - createdAt: new Date(0), - updatedAt: new Date(0), - }); - c.set("tenant", { - id: TENANT, - name: "T1", - slug: "t1", - domain: "t1.test", - parentId: null, - config: {}, - createdAt: new Date(0), - updatedAt: new Date(0), - }); - await next(); - }); - app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); - return { app, grantList, catalog, session, rec }; -})(); - -function buildApp( - grants: GrantRule[], - opts?: { - timelineCatalog?: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - >; - principalId?: string; - }, -) { - sharedBrowser.grantList.length = 0; - sharedBrowser.grantList.push(...grants); - sharedBrowser.catalog.length = 0; - if (opts?.timelineCatalog) sharedBrowser.catalog.push(...opts.timelineCatalog); - sharedBrowser.session.principalId = opts?.principalId ?? PRINCIPAL; - sharedBrowser.rec.added.length = 0; - sharedBrowser.rec.searched.length = 0; - sharedBrowser.rec.tombstoned.length = 0; - sharedBrowser.rec.purged.length = 0; - sharedBrowser.rec.retentionClassChanges.length = 0; - const { added, searched, tombstoned, purged, retentionClassChanges } = - sharedBrowser.rec; - return { - app: sharedBrowser.app, - added, - searched, - tombstoned, - purged, - retentionClassChanges, - }; -} - -/** - * A plane stub for the machine-caller tests that records `tenantId`/ - * `principalId` on every verb (not just `add`) — the shared `stubPlane` - * above deliberately omits them from `search`/`list` to keep its existing - * `.toEqual` assertions exact, so this is a separate stub rather than a - * change to that one. - */ -function stubMachinePlane(opts?: { - timelineCatalog?: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - >; -}) { - const added: { title: string; tenantId: string; principalId: string }[] = []; - const searched: { tenantId: string; principalId: string; query: string }[] = - []; - const fed: { tenantId: string; principalId: string }[] = []; - const tombstoned: string[] = []; - const purged: string[] = []; - const retentionClassChanges: Array<{ versionId: string; retentionClass: string }> = []; - const catalog = opts?.timelineCatalog ?? []; - const plane: Memory = { - capabilities: { embeddingsConfigured: true }, - search: async (p) => { - searched.push({ - tenantId: p.tenantId, - principalId: p.principalId, - query: p.query, - }); - return { items: [], evidence: "none" }; - }, - add: async (p) => { - added.push({ - title: p.content?.title ?? "", - tenantId: p.tenantId, - principalId: p.principalId, - }); - return { documentId: "doc-stub", versionId: "ver-stub" }; - }, - list: async (p) => { - return catalog - .filter( - (e) => - e.tenantId === p.tenantId && - (e.visibleTo === "tenant" || e.visibleTo.includes(p.principalId)), - ) - .map(({ visibleTo: _v, ...event }) => event); - }, - feed: async (p) => { - fed.push({ tenantId: p.tenantId, principalId: p.principalId }); - return { entries: [], nextCursor: null }; - }, - // Same creator-check semantics as stubPlane, for a resolved machine - // caller retiring the memory it created itself (CL-6288 review). - tombstoneDocument: async ({ documentId, principalId }) => { - const doc = RETENTION_DOCS[documentId]; - if (!doc) throw new MemoryError(404, "document not found"); - if (doc.ownerId !== principalId) { - throw new MemoryError(403, "only the document's creator may forget it"); - } - tombstoned.push(documentId); - return { versions: 1 }; - }, - hardDeleteDocument: async ({ documentId, principalId }) => { - const doc = RETENTION_DOCS[documentId]; - if (!doc) throw new MemoryError(404, "document not found"); - if (doc.ownerId !== principalId) { - throw new MemoryError(403, "only the document's creator may purge it"); - } - purged.push(documentId); - return { deleted: true }; - }, - setRetentionClass: async ({ versionId, principalId, retentionClass }) => { - const version = RETENTION_VERSIONS[versionId]; - if (!version) throw new MemoryError(404, "version not found"); - if (version.ownerId !== principalId) { - throw new MemoryError( - 403, - "only the version's creator may change its retention class", - ); - } - retentionClassChanges.push({ versionId, retentionClass }); - return { versionId, documentId: version.documentId, status: "active" }; - }, - close: async () => {}, - }; - return { plane, added, searched, fed, tombstoned, purged, retentionClassChanges }; -} - -// Same shared-app treatment as the browser harness above: the resolver varies -// per test, so it flows through a box the shared app closes over. Resolver -// semantics (null/throwing/async) are covered in deps.test.ts; here only the -// wiring through to the plane matters. -const sharedMachine = (() => { - const catalog: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - > = []; - const rec = stubMachinePlane({ timelineCatalog: catalog }); - const grantList: GrantRule[] = []; - const grantConfig = { - grantStore: createInMemoryGrantStore(grantList), - conditionRegistry: {}, - }; - const resolverBox: { - fn: NonNullable; - } = { fn: () => null }; - const deps: RouteDeps = { - memory: rec.plane, - requireGrant: createRequireGrant(grantConfig), - callerResolver: (c) => resolverBox.fn(c), - }; - // No tenant-session middleware mounted at all — a machine caller has no - // browser session; `callerResolver` is the only source of identity here. - const app = new Hono(); - app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); - return { app, grantList, catalog, resolverBox, rec }; -})(); - -function buildAppWithCallerResolver( - grants: GrantRule[], - callerResolver: RouteDeps["callerResolver"], - opts?: { - timelineCatalog?: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } - >; - }, -) { - sharedMachine.grantList.length = 0; - sharedMachine.grantList.push(...grants); - sharedMachine.catalog.length = 0; - if (opts?.timelineCatalog) sharedMachine.catalog.push(...opts.timelineCatalog); - if (callerResolver !== undefined) sharedMachine.resolverBox.fn = callerResolver; - sharedMachine.rec.added.length = 0; - sharedMachine.rec.searched.length = 0; - sharedMachine.rec.fed.length = 0; - sharedMachine.rec.tombstoned.length = 0; - sharedMachine.rec.purged.length = 0; - sharedMachine.rec.retentionClassChanges.length = 0; - const { added, searched, fed, tombstoned, purged, retentionClassChanges } = - sharedMachine.rec; - return { - app: sharedMachine.app, - added, - searched, - fed, - tombstoned, - purged, - retentionClassChanges, - }; -} - -function buildAppWithoutPrincipal() { - const { plane } = stubPlane(); - const grantConfig = { - grantStore: createInMemoryGrantStore([]), - conditionRegistry: {}, - }; - const deps: RouteDeps = { - memory: plane, - requireGrant: createRequireGrant(grantConfig), - }; - const app = new Hono(); - app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); - return app; -} - -const jsonPost = (body: unknown) => ({ - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), -}); - -const LIST_CATALOG: Array< - TimelineEvent & { visibleTo: readonly string[] | "tenant" } -> = [ - { - at: "2026-01-02T00:00:00.000Z", - title: PUBLIC_TITLE, - source: "mcp", - tenantId: TENANT, - principalId: "alice", - visibleTo: "tenant", - }, - { - at: "2026-01-01T00:00:00.000Z", - title: SECRET_TITLE, - source: "mcp", - tenantId: TENANT, - principalId: "alice", - visibleTo: ["alice"], - }, -]; - -describe("memory HTTP routes", () => { - test("add with the add grant writes under the caller's scope", async () => { - const { app, added } = buildApp([ - grant(PRINCIPAL, "add"), - grant(PRINCIPAL, "search"), - ]); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { documentId: string; versionId: string }; - expect(body.documentId).toBe("doc-stub"); - expect(body.versionId).toBe("ver-stub"); - expect(added).toEqual([ - { title: "t", tenantId: TENANT, principalId: PRINCIPAL }, - ]); - }); - - test("add without the add grant is 403", async () => { - const { app, added } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(403); - expect(added).toHaveLength(0); - }); - - test("legacy capture grant does not authorize add", async () => { - const { app, added } = buildApp([grant(PRINCIPAL, "capture")]); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(403); - expect(added).toHaveLength(0); - }); - - test("add validates the body (400 on missing text)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "add")]); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t" }), - ); - expect(res.status).toBe(400); - }); - - test("search with the search grant returns a result", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hello" }), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - items: unknown[]; - evidence: string; - }; - expect(body.items).toEqual([]); - expect(body.evidence).toBe("none"); - }); - - test("search requires the search grant", async () => { - const { app } = buildApp([grant(PRINCIPAL, "add")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hi" }), - ); - expect(res.status).toBe(403); - }); - - test("legacy find grant does not authorize search", async () => { - const { app } = buildApp([grant(PRINCIPAL, "find")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hi" }), - ); - expect(res.status).toBe(403); - }); - - test("search rejects out-of-range limit (400)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hi", limit: 999 }), - ); - expect(res.status).toBe(400); - }); - - test("search threads kinds and entity_ids through to the plane", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ - query: "hello", - kinds: ["artifact", "task"], - entity_ids: ["e1", "e2"], - }), - ); - expect(res.status).toBe(200); - expect(searched).toEqual([ - { - kinds: ["artifact", "task"], - entityIds: ["e1", "e2"], - limit: undefined, - }, - ]); - }); - - test("search with no kinds/entity_ids leaves them unset on the plane call", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hello" }), - ); - expect(res.status).toBe(200); - expect(searched).toEqual([ - { kinds: undefined, entityIds: undefined, limit: undefined }, - ]); - }); - - test("search rejects a non-string-array kinds (400)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hi", kinds: [1, 2] }), - ); - expect(res.status).toBe(400); - }); - - test("search passes an empty kinds/entity_ids array through unchanged", async () => { - const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hello", kinds: [], entity_ids: [] }), - ); - expect(res.status).toBe(200); - expect(searched).toEqual([ - { kinds: [], entityIds: [], limit: undefined }, - ]); - }); - - test("list requires the search grant", async () => { - const { app } = buildApp([grant(PRINCIPAL, "add")]); - const res = await app.request("/api/tenants/t1/memory/list"); - expect(res.status).toBe(403); - }); - - test("list never returns a title private to another principal", async () => { - const { app } = buildApp([grant(PRINCIPAL, "search")], { - timelineCatalog: LIST_CATALOG, - principalId: PRINCIPAL, - }); - const res = await app.request("/api/tenants/t1/memory/list"); - expect(res.status).toBe(200); - const body = (await res.json()) as { events: TimelineEvent[] }; - expect(body.events.map((e) => e.title)).toEqual([PUBLIC_TITLE]); - expect(body.events.map((e) => e.title)).not.toContain(SECRET_TITLE); - }); - - test("list returns a private title only to the allowed principal", async () => { - const { app } = buildApp([grant("alice", "search")], { - timelineCatalog: LIST_CATALOG, - principalId: "alice", - }); - const res = await app.request("/api/tenants/t1/memory/list"); - expect(res.status).toBe(200); - const body = (await res.json()) as { events: TimelineEvent[] }; - expect(body.events.map((e) => e.title)).toContain(SECRET_TITLE); - }); - - test("missing principal is 401", async () => { - const app = buildAppWithoutPrincipal(); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hi" }), - ); - expect(res.status).toBe(401); - }); -}); - -describe("memory HTTP routes — retention (CL-6288)", () => { - test("forget tombstones the caller's own document", async () => { - const { app, tombstoned } = buildApp([grant(PRINCIPAL, "forget")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-mine/forget", - jsonPost({}), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { documentId: string; versions: number }; - expect(body).toEqual({ documentId: "doc-mine", versions: 1 }); - expect(tombstoned).toEqual(["doc-mine"]); - }); - - test("forget without the memory:forget grant is 403 (search does not authorize it)", async () => { - const { app, tombstoned } = buildApp([ - grant(PRINCIPAL, "search"), - grant(PRINCIPAL, "add"), - ]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-mine/forget", - jsonPost({}), - ); - expect(res.status).toBe(403); - expect(tombstoned).toHaveLength(0); - }); - - test("forget is refused for a document owned by another principal, even with the forget grant", async () => { - // The mirror of CL-6286's cross-tenant test: PRINCIPAL can call forget - // (has the grant) but doc-alice belongs to "alice" — visibility via a - // share is not the same as ownership. - const { app, tombstoned } = buildApp([grant(PRINCIPAL, "forget")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-alice/forget", - jsonPost({}), - ); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("creator"); - expect(tombstoned).toHaveLength(0); - }); - - test("forget 404s for an unknown document", async () => { - const { app } = buildApp([grant(PRINCIPAL, "forget")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-unknown/forget", - jsonPost({}), - ); - expect(res.status).toBe(404); - }); - - test("purge hard-deletes the caller's own document", async () => { - const { app, purged } = buildApp([grant(PRINCIPAL, "purge")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-mine/purge", - jsonPost({}), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { documentId: string; deleted: boolean }; - expect(body).toEqual({ documentId: "doc-mine", deleted: true }); - expect(purged).toEqual(["doc-mine"]); - }); - - test("purge without the memory:purge grant is 403 — the forget grant does not authorize purge", async () => { - const { app, purged } = buildApp([grant(PRINCIPAL, "forget")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-mine/purge", - jsonPost({}), - ); - expect(res.status).toBe(403); - expect(purged).toHaveLength(0); - }); - - test("purge is refused for a document owned by another principal", async () => { - const { app, purged } = buildApp([grant(PRINCIPAL, "purge")]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-alice/purge", - jsonPost({}), - ); - expect(res.status).toBe(403); - expect(purged).toHaveLength(0); - }); - - test("forget and purge are distinct routes — calling forget never hard-deletes", async () => { - const { app, tombstoned, purged } = buildApp([ - grant(PRINCIPAL, "forget"), - grant(PRINCIPAL, "purge"), - ]); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-mine/forget", - jsonPost({}), - ); - expect(res.status).toBe(200); - expect(tombstoned).toEqual(["doc-mine"]); - expect(purged).toHaveLength(0); - }); - - test("retention-class updates the version for its creator", async () => { - const { app, retentionClassChanges } = buildApp([ - grant(PRINCIPAL, "forget"), - ]); - const res = await app.request( - "/api/tenants/t1/memory/versions/ver-mine/retention-class", - jsonPost({ retention_class: "durable" }), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { versionId: string; status: string }; - expect(body.versionId).toBe("ver-mine"); - expect(retentionClassChanges).toEqual([ - { versionId: "ver-mine", retentionClass: "durable" }, - ]); - }); - - test("retention-class rejects an invalid retention_class value (400)", async () => { - const { app } = buildApp([grant(PRINCIPAL, "forget")]); - const res = await app.request( - "/api/tenants/t1/memory/versions/ver-mine/retention-class", - jsonPost({ retention_class: "nonsense" }), - ); - expect(res.status).toBe(400); - }); - - test("retention-class is refused for a version owned by another principal", async () => { - const { app, retentionClassChanges } = buildApp([ - grant(PRINCIPAL, "forget"), - ]); - const res = await app.request( - "/api/tenants/t1/memory/versions/ver-alice/retention-class", - jsonPost({ retention_class: "ephemeral" }), - ); - expect(res.status).toBe(403); - expect(retentionClassChanges).toHaveLength(0); - }); - - // The search-route "missing principal is 401" covers the no-session gate; - // the forget-route twin added nothing (deps.test.ts pins the 401 path). - - test.each([ - [ - "forget", - "forget", - "/api/tenants/t1/memory/documents/%20/forget", - {}, - "tombstoned", - ], - [ - "purge", - "purge", - "/api/tenants/t1/memory/documents/%20/purge", - {}, - "purged", - ], - [ - "retention-class", - "forget", - "/api/tenants/t1/memory/versions/%20/retention-class", - { retention_class: "durable" }, - "retentionClassChanges", - ], - ] as const)( - "%s rejects a whitespace-only id (400, never reaching the plane)", - async (_route, action, path, body, recKey) => { - const built = buildApp([grant(PRINCIPAL, action)]); - const res = await built.app.request(path, jsonPost(body)); - expect(res.status).toBe(400); - expect(built[recKey]).toHaveLength(0); - }, - ); -}); - -describe("memory HTTP routes — machine caller (callerResolver)", () => { - const RUN_TENANT = "tenant-run"; - const RUN_PRINCIPAL = "run-principal"; - - test("add with the add grant writes under the resolved run's scope", async () => { - const { app, added } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "add")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(200); - expect(added).toEqual([ - { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, - ]); - }); - - test("a request body naming a different tenant/principal is ignored — the resolved caller always wins", async () => { - const { app, added } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "add")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ - title: "t", - text: "body", - tenantId: "tenant-evil", - principalId: "attacker", - }), - ); - expect(res.status).toBe(200); - expect(added).toEqual([ - { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, - ]); - }); - - test("grantGuard still applies to a machine caller: no grant is 403", async () => { - const { app, added } = buildAppWithCallerResolver( - [], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(403); - expect(added).toHaveLength(0); - }); - - test("a grant for a different principal does not authorize this machine caller", async () => { - const { app, added } = buildAppWithCallerResolver( - [grant("some-other-principal", "add")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(403); - expect(added).toHaveLength(0); - }); - - test("an unresolvable caller is 401, never falls through to a browser principal", async () => { - const { app } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "add")], - () => null, - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(401); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("unauthorized"); - }); - - // The read paths matter more than `add` here: a bug on these means reading - // ANOTHER TENANT'S memories, not just misattributing a write. - - test("search threads the resolved tenant/principal through to the plane, ignoring the URL's :tenantId", async () => { - const { app, searched } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "search")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - // The path names a DIFFERENT tenant than the resolved caller's. - const res = await app.request( - "/api/tenants/tenant-in-url-not-resolved/memory/search", - jsonPost({ query: "hello" }), - ); - expect(res.status).toBe(200); - expect(searched).toEqual([ - { tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL, query: "hello" }, - ]); - }); - - test("search requires the search grant for the resolved caller (403)", async () => { - const { app, searched } = buildAppWithCallerResolver( - [], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/search", - jsonPost({ query: "hello" }), - ); - expect(res.status).toBe(403); - expect(searched).toHaveLength(0); - }); - - test("list returns only the resolved tenant's events, never another tenant's, regardless of the URL's :tenantId", async () => { - const OTHER_TENANT = "tenant-other"; - const catalog: Array = [ - { - at: "2026-01-02T00:00:00.000Z", - title: "run tenant's note", - source: "mcp", - tenantId: RUN_TENANT, - principalId: RUN_PRINCIPAL, - visibleTo: "tenant", - }, - { - at: "2026-01-01T00:00:00.000Z", - title: "a different tenant's note", - source: "mcp", - tenantId: OTHER_TENANT, - principalId: "someone-else", - visibleTo: "tenant", - }, - ]; - const { app } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "search")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - { timelineCatalog: catalog }, - ); - // The path names the OTHER tenant; the resolved caller must still only - // ever see its own tenant's events. - const res = await app.request(`/api/tenants/${OTHER_TENANT}/memory/list`); - expect(res.status).toBe(200); - const body = (await res.json()) as { events: TimelineEvent[] }; - expect(body.events.map((e) => e.title)).toEqual(["run tenant's note"]); - }); - - test("list requires the search grant for the resolved caller (403)", async () => { - const { app } = buildAppWithCallerResolver( - [], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request("/api/tenants/t1/memory/list"); - expect(res.status).toBe(403); - }); - - test("feed threads the resolved tenant/principal through to the plane, ignoring the URL's :tenantId", async () => { - const { app, fed } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "search")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/tenant-in-url-not-resolved/memory/feed", - ); - expect(res.status).toBe(200); - expect(fed).toEqual([{ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }]); - }); - - test("feed requires the search grant for the resolved caller (403)", async () => { - const { app, fed } = buildAppWithCallerResolver( - [], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request("/api/tenants/t1/memory/feed"); - expect(res.status).toBe(403); - expect(fed).toHaveLength(0); - }); - - // The most likely real-world caller of forget/purge: a workflow-run child - // (resolved via callerResolver, CL-6286) retiring memory it wrote itself. - // If the resolver's principalId ever drifted from created_by_principal_id - // (different derivation, casing, run-address vs principal-address), this - // is exactly what would start 403ing in production instead of a test. - - test("forget tombstones a document the resolved run's own principal created", async () => { - const { app, tombstoned } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "forget")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-run-owned/forget", - jsonPost({}), - ); - expect(res.status).toBe(200); - expect(tombstoned).toEqual(["doc-run-owned"]); - }); - - test("purge hard-deletes a document the resolved run's own principal created", async () => { - const { app, purged } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "purge")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-run-owned/purge", - jsonPost({}), - ); - expect(res.status).toBe(200); - expect(purged).toEqual(["doc-run-owned"]); - }); - - test("retention-class updates a version the resolved run's own principal created", async () => { - const { app, retentionClassChanges } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "forget")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/versions/ver-run-owned/retention-class", - jsonPost({ retention_class: "durable" }), - ); - expect(res.status).toBe(200); - expect(retentionClassChanges).toEqual([ - { versionId: "ver-run-owned", retentionClass: "durable" }, - ]); - }); - - test("forget is still refused for a resolved run caller that is not the creator, even with the grant", async () => { - const { app, tombstoned } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "forget")], - () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), - ); - const res = await app.request( - "/api/tenants/t1/memory/documents/doc-alice/forget", - jsonPost({}), - ); - expect(res.status).toBe(403); - expect(tombstoned).toHaveLength(0); - }); -}); - -describe("memory HTTP routes — resolver trust-boundary and row-fabrication contract", () => { - const RUN_TENANT = "tenant-run"; - const RUN_PRINCIPAL = "run-principal"; - - test("a resolver that throws does not authorize the request (fails closed, no leaked message)", async () => { - const { app, added } = buildAppWithCallerResolver( - [grant(RUN_PRINCIPAL, "add")], - () => { - throw new Error("db connection string with secret=abc123"); - }, - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(500); - const text = await res.text(); - expect(text).not.toContain("secret=abc123"); - expect(added).toHaveLength(0); - }); - - test.each([ - ["empty-string", { tenantId: "", principalId: "" }], - // "string >= 1" would be a LENGTH constraint only -- " " has length 1 - // and would pass it, seating a whitespace-only scope wearing the same - // costume as the empty-string case above. This is the regression guard - // for that boundary (same bug class PR #34 fixed in optionalEnv). - ["whitespace-only", { tenantId: " ", principalId: "\t\n" }], - ] as const)( - "a resolver returning a %s identity is rejected, not seated as a garbage scope", - async (_label, resolved) => { - // A grant that would (wrongly) authorize the malformed principal if - // the identity were ever seated — proving rejection happens before - // grantGuard, not that no grant happened to match. - const { app, added } = buildAppWithCallerResolver( - [grant(resolved.principalId, "add")], - () => resolved, - ); - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("invalid_resolved_caller"); - expect(added).toHaveLength(0); - }, - ); - - /** - * `principalRowFor`/`tenantRowFor` (deps.ts) fabricate `PrincipalRow`/ - * `TenantRow` with placeholder fields for everything Interchange's - * `requireGrant`/`authorize` doesn't read. That's only safe as long as - * `authorize()` (and `caller()`, in-package) never read anything but - * `.id` / `.tenantId`. A Proxy that throws on any other property access - * turns a future Interchange version quietly reading a fabricated field - * (`status`, `kind`, `parentId`, `config`, ...) into a loud test failure - * instead of silent authorization on fiction. - */ - test("requireGrant/authorize and caller() never read a fabricated row field beyond .id / .tenantId", async () => { - function canary(row: T, allowed: (keyof T)[]): T { - return new Proxy(row, { - get(target, prop, receiver) { - if ( - typeof prop === "string" && - !allowed.includes(prop as keyof T) - ) { - throw new Error( - `unexpected field access on a synthesized row: ${prop}`, - ); - } - return Reflect.get(target, prop, receiver); - }, - }); - } - - const canaryPrincipal = canary( - { - id: RUN_PRINCIPAL, - tenantId: RUN_TENANT, - kind: "agent" as const, - refId: RUN_PRINCIPAL, - status: "active" as const, - createdAt: new Date(0), - updatedAt: new Date(0), - }, - ["id", "tenantId"], - ); - const canaryTenant = canary( - { - id: RUN_TENANT, - name: RUN_TENANT, - slug: RUN_TENANT, - domain: "", - parentId: null, - config: null, - createdAt: new Date(0), - updatedAt: new Date(0), - }, - ["id"], - ); - - const { plane, added } = stubMachinePlane(); - const grantConfig = { - grantStore: createInMemoryGrantStore([grant(RUN_PRINCIPAL, "add")]), - conditionRegistry: {}, - }; - const deps: RouteDeps = { - memory: plane, - requireGrant: createRequireGrant(grantConfig), - }; - const app = new Hono(); - // Seat the canary rows directly, bypassing resolveCaller/callerResolver: - // this isolates the contract under test (does anything downstream of - // context read more than .id / .tenantId) from resolveCaller's own - // fabrication, which is exercised separately above. - app.use("*", async (c, next) => { - c.set("principal", canaryPrincipal); - c.set("tenant", canaryTenant); - await next(); - }); - app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); - - const res = await app.request( - "/api/tenants/t1/memory/add", - jsonPost({ title: "t", text: "body" }), - ); - expect(res.status).toBe(200); - expect(added).toEqual([ - { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, - ]); - }); -}); diff --git a/src/services/capture.test.ts b/src/services/capture.test.ts index d9b6a5b..c4a8ee7 100644 --- a/src/services/capture.test.ts +++ b/src/services/capture.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, mock } from "bun:test"; -import { embedInsertedChunksWithConfig, toEmbedClientConfig } from "./capture.js"; +import { + captureDocument, + createBackgroundEmbedScheduler, + embedInsertedChunksWithConfig, + findPendingChunks, + runBackgroundEmbedPass, + toEmbedClientConfig, +} from "./capture.js"; import type { CaptureInput } from "./capture.js"; import type { Db, RawSql } from "../db/client.js"; import type { EngineConfig } from "../config.js"; @@ -122,21 +129,8 @@ function unreachableRawSql(): RawSql { } as unknown as RawSql; } -// memory.test.ts uses `mock.module("./services/capture.ts", ...)` around its -// own describe blocks; Bun's module registry is process-global, so a static -// `import { captureDocument } from "./capture.js"` here can end up bound to -// that mock's fixture data when the whole suite runs (the same leak -// services/search.test.ts documents for hybridSearch). A cache-busted -// dynamic import — the same trick memory.test.ts itself uses for -// `./memory.ts` — sidesteps this: a fresh module specifier is never the one -// any mock.module call replaced. -async function loadRealCapture(): Promise { - return import(`./capture.ts?cl-8615-real=${Date.now()}-${Math.random()}`); -} - describe("captureDocument defers embedding to the background (CL-8615)", () => { it("returns captured with zero fetch calls when the embedder hangs, scheduling the embed pass instead", async () => { - const { captureDocument } = await loadRealCapture(); const hangingFetch = mock( () => new Promise(() => {}), ) as unknown as typeof fetch; @@ -204,7 +198,6 @@ describe("captureDocument defers embedding to the background (CL-8615)", () => { describe("runBackgroundEmbedPass retries then sweeps pending (CL-8615)", () => { it("retries after a refused embed call with the 1s delay, then embeds fresh chunks and sweeps one pending chunk", async () => { - const { runBackgroundEmbedPass } = await loadRealCapture(); let fetchCalls = 0; const flakyFetch = (async (_url: unknown, init?: { body?: unknown }) => { fetchCalls++; @@ -265,7 +258,6 @@ describe("runBackgroundEmbedPass retries then sweeps pending (CL-8615)", () => { describe("background embed pass honors the configured embed timeout (CL-8615)", () => { it("threads EngineConfig.embed.timeoutMs through to every fetch init signal", async () => { - const { runBackgroundEmbedPass } = await loadRealCapture(); const timeoutMs = 45000; const seenSignals: unknown[] = []; const timeoutArgs: number[] = []; @@ -314,7 +306,6 @@ describe("background embed pass honors the configured embed timeout (CL-8615)", describe("findPendingChunks skips tombstoned and non-live versions", () => { it("joins memory.version and binds live generation so a forget-then-add cannot sweep [redacted] text", async () => { - const { findPendingChunks } = await loadRealCapture(); const queries: Array<{ sql: string; params: readonly unknown[] }> = []; const client = { query: (sql: string, params: readonly unknown[]) => { @@ -342,7 +333,6 @@ describe("findPendingChunks skips tombstoned and non-live versions", () => { describe("background embed scheduler serializes overlapping adds", () => { it("overlapping enqueues for one tenant share one in-flight pass and never overlap run()", async () => { - const { createBackgroundEmbedScheduler } = await loadRealCapture(); const scheduler = createBackgroundEmbedScheduler(); let current = 0; const batches: string[][] = []; @@ -391,7 +381,6 @@ describe("background embed scheduler serializes overlapping adds", () => { }); it("passes for two tenants still never run concurrently", async () => { - const { createBackgroundEmbedScheduler } = await loadRealCapture(); const scheduler = createBackgroundEmbedScheduler(); let releaseFirst!: () => void; const held = new Promise((resolve) => { diff --git a/src/services/search.test.ts b/src/services/search.test.ts index e051502..f1e52d8 100644 --- a/src/services/search.test.ts +++ b/src/services/search.test.ts @@ -648,9 +648,9 @@ describe("hybridSearch — embed unconfigured (CL-6287)", () => { }; } - // The no-embed lexical dispatch itself is covered by memory.test.ts's - // wiring-blocked plane tests; what stays here is the degraded-flag - // contract (configured-off reads differently from a runtime failure). + // The no-embed lexical dispatch itself is covered end to end by tests/; + // what stays here is the degraded-flag contract (configured-off reads + // differently from a runtime failure). it("reports dense_unavailable and lexical_only together, distinguishing configured-off from a runtime failure", async () => { const result = await hybridSearch( { diff --git a/tests/caller-resolver.test.ts b/tests/caller-resolver.test.ts new file mode 100644 index 0000000..896f871 --- /dev/null +++ b/tests/caller-resolver.test.ts @@ -0,0 +1,205 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore, type GrantStore } from "@intx/authz"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; + +import type { Memory } from "../src/memory.ts"; +import { + createMemoryRoutes, + type CallerResolver, +} from "../src/routes/mount.ts"; +import { + allow, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +// A machine caller (e.g. a workflow run) never passes the host's session +// middleware; the host's callerResolver names its tenant and principal. +describe.skipIf(testDatabaseUrl() === undefined)("machine callers through callerResolver", () => { + let db: TestDb; + let memory: Memory | undefined; + let grantStore: GrantStore; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "run"); + await seedPrincipal(db, "acme", "other-run"); + await seedPrincipal(db, "globex", "globex-user"); + grantStore = createInMemoryGrantStore([ + ...["add", "search", "forget", "purge"].map((a) => allow("run", a)), + ...["add", "search", "forget"].map((a) => allow("other-run", a)), + ...["add", "search"].map((a) => allow("globex-user", a)), + // Would expose globex's tenant-shared documents if a route ever + // scoped a read to the URL's tenant instead of the resolved one. + { + id: "g-run-globex-tag", + resource: "memory.tenant:globex", + action: "search", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: "run", + }, + allow("", "add"), + allow("\t\n", "add"), + ]); + memory = createTestMemory(db, grantStore); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + function appFor(callerResolver: CallerResolver): Hono { + const app = new Hono(); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory: memory as Memory, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: {} }), + callerResolver, + }), + ); + return app; + } + + const as = (tenantId: string, principalId: string) => + appFor(() => ({ tenantId, principalId })); + + function post(app: Hono, path: string, body: unknown = {}) { + return app.request(`/api/tenants/ignored/memory${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + async function add(app: Hono, title: string): Promise { + const res = await post(app, "/add", { title, text: `${title} body` }); + expect(res.status).toBe(200); + return ((await res.json()) as { documentId: string }).documentId; + } + + test("add writes under the resolved scope, ignoring identity in the body", async () => { + const res = await post(as("acme", "run"), "/add", { + title: "Run note", + text: "written by the run", + tenantId: "globex", + principalId: "globex-user", + }); + expect(res.status).toBe(200); + const { documentId } = (await res.json()) as { documentId: string }; + const [row] = await db.sql<{ tenant_id: string; created_by_principal_id: string }[]>` + SELECT d.tenant_id, v.created_by_principal_id + FROM memory.document d JOIN memory.version v ON v.document_id = d.id + WHERE d.id = ${documentId}`; + expect(row).toEqual({ tenant_id: "acme", created_by_principal_id: "run" }); + }); + + async function documentCount(): Promise { + const [row] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.document`; + return row?.n ?? 0; + } + + test("the resolved caller still needs the grant on every route", async () => { + await seedPrincipal(db, "acme", "ungranted"); + const ungranted = as("acme", "ungranted"); + expect((await post(ungranted, "/add", { title: "t", text: "b" })).status).toBe(403); + expect((await post(ungranted, "/search", { query: "q" })).status).toBe(403); + expect((await ungranted.request("/api/tenants/acme/memory/list")).status).toBe(403); + expect((await ungranted.request("/api/tenants/acme/memory/feed")).status).toBe(403); + }); + + test("a resolver that cannot identify the caller is 401", async () => { + const res = await post(appFor(() => null), "/add", { title: "t", text: "b" }); + expect(res.status).toBe(401); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe( + "unauthorized", + ); + }); + + test("a resolver that throws fails closed without leaking its message", async () => { + const before = await documentCount(); + const res = await post( + appFor(() => { + throw new Error("db connection string with secret=abc123"); + }), + "/add", + { title: "t", text: "b" }, + ); + expect(res.status).toBe(500); + expect(await res.text()).not.toContain("secret=abc123"); + expect(await documentCount()).toBe(before); + }); + + test.each([ + ["empty", { tenantId: "", principalId: "" }], + ["whitespace", { tenantId: " ", principalId: "\t\n" }], + ] as const)("a resolver returning a %s identity is a 500, never a seated scope", async (_label, resolved) => { + // The malformed principal holds an add grant, so a 500 proves the + // identity was rejected before the grant check. + const before = await documentCount(); + const res = await post(appFor(() => resolved), "/add", { title: "t", text: "b" }); + expect(await documentCount()).toBe(before); + expect(res.status).toBe(500); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe( + "invalid_resolved_caller", + ); + }); + + test("search, list and feed read only the resolved tenant, whatever the URL says", async () => { + const globexShare = await post(as("globex", "globex-user"), "/add", { + title: "Globex secret plan", + text: "globex secret plan body", + share: { tenant: true }, + }); + expect(globexShare.status).toBe(200); + await add(as("acme", "run"), "Acme run plan"); + const acme = as("acme", "run"); + + const search = await acme.request("/api/tenants/globex/memory/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "plan" }), + }); + expect(search.status).toBe(200); + const { items } = (await search.json()) as { items: { title: string }[] }; + expect(items.map((i) => i.title)).toContain("Acme run plan"); + expect(items.map((i) => i.title)).not.toContain("Globex secret plan"); + + for (const route of ["list", "feed"]) { + const res = await acme.request(`/api/tenants/globex/memory/${route}`); + expect(res.status).toBe(200); + const body = JSON.stringify(await res.json()); + expect(body).toContain("Acme run plan"); + expect(body).not.toContain("Globex secret plan"); + } + }); + + test("forget, purge and retention-class work for the resolved creator only", async () => { + const owner = as("acme", "run"); + const other = as("acme", "other-run"); + + const forgotten = await add(owner, "Forget via run"); + expect((await post(other, `/documents/${forgotten}/forget`)).status).toBe(403); + expect((await post(owner, `/documents/${forgotten}/forget`)).status).toBe(200); + + const purged = await add(owner, "Purge via run"); + expect((await post(owner, `/documents/${purged}/purge`)).status).toBe(200); + + const classed = await add(owner, "Retention via run"); + const [version] = await db.sql<{ id: string }[]>` + SELECT id FROM memory.version WHERE document_id = ${classed}`; + const path = `/versions/${version?.id}/retention-class`; + expect((await post(other, path, { retention_class: "durable" })).status).toBe(403); + expect((await post(owner, path, { retention_class: "durable" })).status).toBe(200); + }); +}); diff --git a/tests/context-rows.test.ts b/tests/context-rows.test.ts new file mode 100644 index 0000000..22dc782 --- /dev/null +++ b/tests/context-rows.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import { Hono } from "hono"; + +import type { Memory } from "../src/memory.ts"; +import { createMemoryRoutes } from "../src/routes/mount.ts"; +import { + allow, + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + type TestDb, +} from "./lib/db-harness.ts"; + +describe.skipIf(testDatabaseUrl() === undefined)("synthesized context rows", () => { + let db: TestDb; + let memory: Memory | undefined; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "run"); + }); + + afterAll(async () => { + await memory?.close(); + await db?.close(); + }); + + // The rows resolveCaller seats carry placeholder fields; this fails loudly + // if authorization or the routes ever read one. + test("requireGrant and the routes read only .id and .tenantId", async () => { + function canary(row: T, allowed: (keyof T)[]): T { + return new Proxy(row, { + get(target, prop, receiver) { + if (typeof prop === "string" && !allowed.includes(prop as keyof T)) { + throw new Error(`unexpected field access on a synthesized row: ${prop}`); + } + return Reflect.get(target, prop, receiver); + }, + }); + } + const grantStore = createInMemoryGrantStore([allow("run", "add")]); + memory = createTestMemory(db, grantStore); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set( + "principal", + canary( + { + id: "run", + tenantId: "acme", + kind: "agent", + refId: "run", + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ["id", "tenantId"], + ), + ); + c.set( + "tenant", + canary( + { + id: "acme", + name: "acme", + slug: "acme", + domain: "", + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ["id"], + ), + ); + await next(); + }); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory, + requireGrant: createRequireGrant({ grantStore, conditionRegistry: {} }), + }), + ); + const res = await app.request("/api/tenants/acme/memory/add", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "t", text: "body" }), + }); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/grants.test.ts b/tests/grants.test.ts index 371813f..9c6d68e 100644 --- a/tests/grants.test.ts +++ b/tests/grants.test.ts @@ -21,13 +21,26 @@ describe.skipIf(testDatabaseUrl() === undefined)("grants, forget and purge", () beforeAll(async () => { db = await createTestDb(); - for (const principal of ["alice", "carol", "dave"]) { + for (const principal of ["alice", "carol", "dave", "erin", "frank"]) { await seedPrincipal(db, "acme", principal); } const grantStore = createInMemoryGrantStore([ ...["add", "search", "forget", "purge"].map((a) => allow("alice", a)), ...["search", "forget", "purge"].map((a) => allow("carol", a)), + { + id: "g-carol-tenant-tag", + resource: "memory.tenant:acme", + action: "search", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: "carol", + }, allow("dave", "add"), + ...["search", "forget"].map((a) => allow("erin", a)), + ...["capture", "find"].map((a) => allow("frank", a)), ]); memory = createTestMemory(db, grantStore); app = createTestApp({ @@ -37,6 +50,8 @@ describe.skipIf(testDatabaseUrl() === undefined)("grants, forget and purge", () alice: { tenantId: "acme", principalId: "alice" }, carol: { tenantId: "acme", principalId: "carol" }, dave: { tenantId: "acme", principalId: "dave" }, + erin: { tenantId: "acme", principalId: "erin" }, + frank: { tenantId: "acme", principalId: "frank" }, }, }); }); @@ -100,4 +115,125 @@ describe.skipIf(testDatabaseUrl() === undefined)("grants, forget and purge", () (SELECT count(*)::int FROM memory.chunk WHERE document_id = ${documentId}) AS chunks`; expect(left).toEqual({ docs: 0, versions: 0, chunks: 0 }); }); + + test("legacy capture and find grants authorize neither add nor search", async () => { + expect((await post("frank", "/add", { title: "t", text: "b" })).status).toBe(403); + expect((await post("frank", "/search", { query: "q" })).status).toBe(403); + }); + + test("a request without a session is 401", async () => { + const res = await app.request("/api/tenants/acme/memory/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "q" }), + }); + expect(res.status).toBe(401); + }); + + test("malformed bodies and out-of-range limits are 400", async () => { + expect((await post("alice", "/add", { title: "no text" })).status).toBe(400); + expect((await post("alice", "/search", { query: "q", limit: 51 })).status).toBe(400); + expect((await post("alice", "/search", { query: "q", kinds: [1] })).status).toBe(400); + }); + + test("list and search show a document to its creator and to holders of a grant on its tags only", async () => { + await post("alice", "/add", { title: "Alice private", text: "only alice" }); + await post("alice", "/add", { + title: "Shared with carol", + text: "shared with the tenant", + share: { tenant: true }, + }); + const titles = async (token: string) => { + const res = await app.request("/api/tenants/acme/memory/list", { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + return JSON.stringify(await res.json()); + }; + const forAlice = await titles("alice"); + const forCarol = await titles("carol"); + const forErin = await titles("erin"); + expect(forAlice).toContain("Alice private"); + expect(forCarol).toContain("Shared with carol"); + expect(forCarol).not.toContain("Alice private"); + expect(forErin).not.toContain("Alice private"); + expect(forErin).not.toContain("Shared with carol"); + + const searched = async (token: string) => { + const res = await post(token, "/search", { query: "only alice" }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + return items.map((i) => i.title); + }; + expect(await searched("alice")).toContain("Alice private"); + expect(await searched("carol")).not.toContain("Alice private"); + expect(await searched("erin")).not.toContain("Alice private"); + }); + + test("purge is refused for a non-creator holding the purge grant", async () => { + const documentId = await addDocument("Carol cannot purge"); + expect((await post("carol", `/documents/${documentId}/purge`)).status).toBe(403); + }); + + test("list needs the search grant", async () => { + const res = await app.request("/api/tenants/acme/memory/list", { + headers: { authorization: "Bearer dave" }, + }); + expect(res.status).toBe(403); + }); + + test("search passes kinds and entity_ids through, and empty arrays filter nothing", async () => { + await post("alice", "/add", { title: "Mango decision", text: "Mango wins.", kind: "decision" }); + await post("alice", "/add", { title: "Mango note", text: "Mango is ripe." }); + const titles = async (body: Record) => { + const res = await post("alice", "/search", body); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { items: { title: string }[] }; + return items.map((i) => i.title).sort(); + }; + expect(await titles({ query: "mango", kinds: ["decision"] })).toEqual(["Mango decision"]); + expect(await titles({ query: "mango", kinds: [], entity_ids: [] })).toEqual([ + "Mango decision", + "Mango note", + ]); + expect(await titles({ query: "mango", entity_ids: ["entity-none"] })).toEqual([]); + }); + + test("the forget grant does not authorize purge, and forget never deletes rows", async () => { + const documentId = await addDocument("Erin cannot purge"); + await post("alice", `/documents/${documentId}/forget`); + expect((await post("erin", `/documents/${documentId}/purge`)).status).toBe(403); + const [row] = await db.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM memory.document WHERE id = ${documentId}`; + expect(row?.n).toBe(1); + }); + + test("unknown documents and versions are 404, and whitespace ids are 400", async () => { + expect((await post("alice", "/documents/doc-missing/forget")).status).toBe(404); + expect((await post("alice", "/documents/doc-missing/purge")).status).toBe(404); + expect( + (await post("alice", "/versions/ver-missing/retention-class", { retention_class: "durable" })) + .status, + ).toBe(404); + expect((await post("alice", "/documents/%20/forget")).status).toBe(400); + expect((await post("alice", "/documents/%20/purge")).status).toBe(400); + expect( + (await post("alice", "/versions/%20/retention-class", { retention_class: "durable" })) + .status, + ).toBe(400); + }); + + test("retention-class changes a version for its creator only and validates the class", async () => { + const documentId = await addDocument("Retention target"); + const [version] = await db.sql<{ id: string }[]>` + SELECT id FROM memory.version WHERE document_id = ${documentId}`; + const path = `/versions/${version?.id}/retention-class`; + + expect((await post("alice", path, { retention_class: "forever" })).status).toBe(400); + expect((await post("carol", path, { retention_class: "durable" })).status).toBe(403); + expect((await post("alice", path, { retention_class: "durable" })).status).toBe(200); + const [row] = await db.sql<{ retention_class: string }[]>` + SELECT retention_class FROM memory.version WHERE id = ${version?.id ?? ""}`; + expect(row?.retention_class).toBe("durable"); + }); }); diff --git a/tests/lib/db-harness.ts b/tests/lib/db-harness.ts index 64e3921..bb1cc2b 100644 --- a/tests/lib/db-harness.ts +++ b/tests/lib/db-harness.ts @@ -10,6 +10,7 @@ import { Hono } from "hono"; import postgres from "postgres"; import { createMemory, type Memory } from "../../src/memory.ts"; +import type { MemoryConfig } from "../../src/mount-config.ts"; import { runMemoryMigrations } from "../../src/migrations.ts"; import { createMemoryRoutes } from "../../src/routes/mount.ts"; @@ -109,23 +110,28 @@ export async function seedPrincipal( VALUES (${principalId}, ${tenantId}, 'user', ${principalId}, 'active')`; } -/** The engine-backed plane on the test database, lexical-only. */ -export function createTestMemory(db: TestDb, grantStore: GrantStore): Memory { - return createMemory({ - config: { - memory: { - databaseUrl: db.databaseUrl, - dbPoolMax: 4, - ftsLanguage: FTS_LANGUAGE, - rerank: { - baseUrl: undefined, - model: undefined, - apiKey: undefined, - maxDocChars: undefined, - timeoutMs: undefined, - }, +/** Engine config for the test database: lexical-only, no reranker. */ +export function testMemoryConfig(db: TestDb): MemoryConfig { + return { + memory: { + databaseUrl: db.databaseUrl, + dbPoolMax: 4, + ftsLanguage: FTS_LANGUAGE, + rerank: { + baseUrl: undefined, + model: undefined, + apiKey: undefined, + maxDocChars: undefined, + timeoutMs: undefined, }, }, + }; +} + +/** The engine-backed memory on the test database. */ +export function createTestMemory(db: TestDb, grantStore: GrantStore): Memory { + return createMemory({ + config: testMemoryConfig(db), grantStore, conditionRegistry: {}, }); diff --git a/tests/plane.test.ts b/tests/plane.test.ts new file mode 100644 index 0000000..5162215 --- /dev/null +++ b/tests/plane.test.ts @@ -0,0 +1,245 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createInMemoryGrantStore } from "@intx/authz"; + +import { RerankConfigError } from "../src/core/rerank-client.ts"; +import { + createMemory, + MemoryError, + type Memory, + type MemoryAddParams, +} from "../src/memory.ts"; +import { + createTestDb, + createTestMemory, + seedPrincipal, + testDatabaseUrl, + testMemoryConfig, + type TestDb, +} from "./lib/db-harness.ts"; + +async function rejection(run: Promise): Promise { + const err = await run.then( + () => undefined, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(MemoryError); + return err as MemoryError; +} + +describe.skipIf(testDatabaseUrl() === undefined)("in-process memory", () => { + let db: TestDb; + let memory: Memory | undefined; + let withExtractor: Memory | undefined; + + beforeAll(async () => { + db = await createTestDb(); + await seedPrincipal(db, "acme", "alice"); + memory = createTestMemory(db, createInMemoryGrantStore([])); + withExtractor = createMemory({ + config: testMemoryConfig(db), + textExtractor: { + extract: async ({ filename }) => ({ + text: `extracted from ${filename}`, + title: "From extractor", + }), + }, + }); + }); + + afterAll(async () => { + await memory?.close(); + await withExtractor?.close(); + await db?.close(); + }); + + async function accessTags(documentId: string): Promise { + const [row] = await db.sql<{ access_tags: string[] }[]>` + SELECT access_tags FROM memory.document WHERE id = ${documentId}`; + return [...(row?.access_tags ?? [])].sort(); + } + + test.each([ + ["search below 1", (m: Memory) => + m.search({ tenantId: "acme", principalId: "alice", query: "q", limit: 0 })], + ["search above 50", (m: Memory) => + m.search({ tenantId: "acme", principalId: "alice", query: "q", limit: 51 })], + ["list below 1", (m: Memory) => + m.list({ tenantId: "acme", principalId: "alice", limit: 0 })], + ["list above 100", (m: Memory) => + m.list({ tenantId: "acme", principalId: "alice", limit: 101 })], + ] as const)("rejects a limit %s with a 400", async (_label, call) => { + const err = await rejection(call(memory as Memory)); + expect(err.status).toBe(400); + }); + + test("add needs exactly one of content or file", async () => { + const neither = await rejection((memory as Memory).add({ tenantId: "acme", principalId: "alice" } as MemoryAddParams)); + const both = await rejection( + (memory as Memory).add({ + tenantId: "acme", + principalId: "alice", + content: { title: "T", text: "body" }, + file: { bytes: new Uint8Array([1]) }, + }), + ); + for (const err of [neither, both]) { + expect(err.status).toBe(400); + expect(err.message).toContain("content or file"); + } + }); + + test("add with a file needs a textExtractor and stores what it extracts", async () => { + const file = { bytes: new Uint8Array([1, 2, 3]), filename: "note.pdf" }; + const missing = await rejection( + (memory as Memory).add({ tenantId: "acme", principalId: "alice", file }), + ); + expect(missing.status).toBe(400); + expect(missing.message).toContain("textExtractor"); + + const { documentId } = await (withExtractor as Memory).add({ + tenantId: "acme", + principalId: "alice", + file, + }); + const [row] = await db.sql<{ title: string; text: string }[]>` + SELECT d.title, c.text FROM memory.document d + JOIN memory.chunk c ON c.document_id = d.id + WHERE d.id = ${documentId}`; + expect(row).toEqual({ title: "From extractor", text: "extracted from note.pdf" }); + }); + + test("share maps to access tags, peers also get the document tag, and the default is owner-only", async () => { + const m = memory as Memory; + const owner = await m.add({ + tenantId: "acme", + principalId: "alice", + content: { title: "Private", text: "private body" }, + }); + const tenant = await m.add({ + tenantId: "acme", + principalId: "alice", + content: { title: "Team", text: "team body" }, + share: { tenant: true }, + }); + const peers = await m.add({ + tenantId: "acme", + principalId: "alice", + content: { title: "Peers", text: "peers body" }, + share: { principals: ["bob", "carol"] }, + }); + + expect(await accessTags(owner.documentId)).toEqual(["memory.owner:alice"]); + expect(await accessTags(tenant.documentId)).toEqual([ + "memory.owner:alice", + "memory.tenant:acme", + ]); + expect(await accessTags(peers.documentId)).toEqual([ + `memory.doc:${peers.documentId}`, + "memory.owner:alice", + "memory.owner:bob", + "memory.owner:carol", + ]); + }); + + test("search filters by kind and returns evidence only when asked", async () => { + const m = memory as Memory; + await m.add({ + tenantId: "acme", + principalId: "alice", + content: { title: "Kiwi decision", text: "We chose kiwi for the logo." }, + kind: "decision", + }); + await m.add({ + tenantId: "acme", + principalId: "alice", + content: { title: "Kiwi note", text: "Kiwi is a fruit." }, + }); + + const decisions = await m.search({ + tenantId: "acme", + principalId: "alice", + query: "kiwi", + kinds: ["decision"], + }); + expect(decisions.items.map((i) => i.title)).toEqual(["Kiwi decision"]); + expect("evidence" in decisions).toBe(false); + + const withEvidence = await m.search({ + tenantId: "acme", + principalId: "alice", + query: "kiwi", + includeEvidence: true, + }); + expect(withEvidence.items.length).toBe(2); + expect(withEvidence.evidence).toBeDefined(); + }); + + test("reports whether dense retrieval is configured", async () => { + expect((memory as Memory).capabilities.embeddingsConfigured).toBe(false); + const lexical = testMemoryConfig(db); + const dense = createMemory({ + config: { + memory: { + databaseUrl: lexical.memory.databaseUrl, + dbPoolMax: lexical.memory.dbPoolMax, + ftsLanguage: lexical.memory.ftsLanguage, + embed: { + baseUrl: "http://embed.test", + model: "nomic-embed-text", + apiStyle: "ollama", + apiKey: undefined, + timeoutMs: undefined, + }, + rerank: lexical.memory.rerank, + }, + }, + }); + expect(dense.capabilities.embeddingsConfigured).toBe(true); + await dense.close(); + const custom = createMemory({ + documentStore: { + add: async () => ({ documentId: "d1", versionId: "v1" }), + search: async () => ({ items: [] }), + list: async () => [], + close: async () => {}, + }, + }); + expect(custom.capabilities.embeddingsConfigured).toBe(true); + await custom.close(); + }); + + test("re-adding identical content returns the existing document", async () => { + const m = memory as Memory; + const params = { + tenantId: "acme", + principalId: "alice", + content: { title: "Same", text: "identical body" }, + externalRef: "same-ref", + }; + const first = await m.add(params); + const second = await m.add(params); + expect(second.documentId).toBe(first.documentId); + }); + + test("construction fails on a rerank budget that overflows the model", () => { + const config = testMemoryConfig(db); + expect(() => + createMemory({ + config: { + memory: { + databaseUrl: config.memory.databaseUrl, + dbPoolMax: config.memory.dbPoolMax, + ftsLanguage: config.memory.ftsLanguage, + rerank: { + baseUrl: "http://tei.test", + model: "bge-reranker-base", + apiKey: undefined, + maxDocChars: 5_000, + timeoutMs: undefined, + }, + }, + }, + }), + ).toThrow(RerankConfigError); + }); +});