import test from "node:test"; import assert from "node:assert/strict"; import { ACTION_PROVENANCE_LABELS, writeNodeWithEdges, type GraphRelationship, } from "../index.js"; // writeNodeWithEdges now rejects writes whose accountId does not // match the spawning process's ACCOUNT_ID. These provenance-gate tests // predate that gate; they pass a literal `expectedAccountId` so they // exercise only the provenance gate they were written to test. const TEST_ACCOUNT_ID = "12345678-9abc-def0-1234-56789abcdef0"; // Stub session/tx — captures Cypher calls and returns canned results. interface StubTx { run(cypher: string, params?: Record): Promise<{ records: { get(field: string): unknown }[]; summary?: { counters: { updates(): { relationshipsCreated: number } } }; }>; } function makeStubSession(opts: { preCheckLabelsByTarget: Map; edgesCreatedPerCall?: number; }) { const calls: { cypher: string; params: Record }[] = []; const tx: StubTx = { async run(cypher: string, params: Record = {}) { calls.push({ cypher, params }); if (cypher.includes("RETURN elementId(t) AS id, labels(t) AS labels")) { const ids = (params.ids as string[]) ?? []; const records = ids .filter((id) => opts.preCheckLabelsByTarget.has(id)) .map((id) => ({ get(field: string) { if (field === "id") return id; if (field === "labels") return opts.preCheckLabelsByTarget.get(id) ?? []; throw new Error(`unknown field ${field}`); }, })); return { records }; } if (cypher.includes("CREATE (n:")) { return { records: [ { get(field: string) { if (field === "nodeId") return "new-element-id"; if (field === "nodeLabels") return ["Person"]; throw new Error(`unknown field ${field}`); }, }, ], }; } if (cypher.includes("CREATE (a)-[") || cypher.includes("CREATE (b)-[")) { return { records: [], summary: { counters: { updates() { return { relationshipsCreated: opts.edgesCreatedPerCall ?? 1 }; }, }, }, }; } return { records: [] }; }, }; const session = { async executeWrite(work: (tx: StubTx) => Promise): Promise { return await work(tx); }, }; return { session, calls }; } test("ACTION_PROVENANCE_LABELS includes the documented set", () => { for (const label of [ "Person", "UserProfile", "AdminUser", "Organization", "LocalBusiness", "CloudflareTunnel", "CloudflareHostname", ]) { assert.ok( ACTION_PROVENANCE_LABELS.has(label), `${label} should be in ACTION_PROVENANCE_LABELS`, ); } }); // Task 580 relaxed missing-provenance from a hard reject to a soft warn. // The write now proceeds; a single structured stderr line carries the labels // and agent so operators can quantify how often the gap fires. test("writeNodeWithEdges accepts :Person write missing any inbound PRODUCED edge and emits a warn line", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([["other-target", ["LocalBusiness"]]]), }); const relationships: GraphRelationship[] = [ // No inbound PRODUCED at all — neither Task nor Conversation nor Message. { type: "PART_OF", direction: "outgoing", targetNodeId: "other-target" }, ]; const captured: string[] = []; const originalWrite = process.stderr.write.bind(process.stderr); // eslint-disable-next-line @typescript-eslint/no-explicit-any process.stderr.write = ((chunk: any) => { captured.push(typeof chunk === "string" ? chunk : String(chunk)); return true; }) as typeof process.stderr.write; try { const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); } finally { process.stderr.write = originalWrite; } const warnLine = captured.find((l) => l.includes("[graph-write] warn reason=missing-provenance")); assert.ok(warnLine, `expected warn line; captured=${JSON.stringify(captured)}`); assert.match(warnLine!, /labels=Person/); assert.match(warnLine!, /agent=maxy-admin/); }); test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Task edge", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([ ["task-id", ["Task"]], ]), }); const relationships: GraphRelationship[] = [ { type: "PRODUCED", direction: "incoming", targetNodeId: "task-id" }, ]; const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); }); test("writeNodeWithEdges accepts :Person write with no Task edge when agent='system' (bootstrap exemption)", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([["admin-user-id", ["AdminUser"]]]), }); const relationships: GraphRelationship[] = [ { type: "OWNS", direction: "incoming", targetNodeId: "admin-user-id" }, ]; const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "system", source: "writeAdminUserAndPerson" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); }); // gate widened to accept:Conversation provenance for direct // admin asks. The wrapper auto-injects the inbound PRODUCED edge from the // active :AdminConversation via SESSION_NODE_ID. test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Conversation edge", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([ // AdminConversation carries the parent :Conversation label too. ["conv-id", ["Conversation", "AdminConversation"]], ]), }); const relationships: GraphRelationship[] = [ { type: "PRODUCED", direction: "incoming", targetNodeId: "conv-id" }, ]; const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); }); // Message subtypes (UserMessage, AssistantMessage, AdminMessage) // also qualify because the labels() array includes the parent :Message label. test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Message edge", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([ ["msg-id", ["Message", "UserMessage"]], ]), }); const relationships: GraphRelationship[] = [ { type: "PRODUCED", direction: "incoming", targetNodeId: "msg-id" }, ]; const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); }); // Defence-in-depth: a PRODUCED edge whose source is not in // {Task, Conversation, Message} does not satisfy the gate. Under the // Task 580 soft semantics the write still proceeds and the warn line fires — // the source-label allowlist now governs whether the write counts as // provenance-satisfied for the warn check, not whether it lands. test("writeNodeWithEdges accepts :Person write with PRODUCED edge from a non-provenance-source label and emits a warn line", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([ ["business-id", ["LocalBusiness"]], ]), }); const relationships: GraphRelationship[] = [ { type: "PRODUCED", direction: "incoming", targetNodeId: "business-id" }, ]; const captured: string[] = []; const originalWrite = process.stderr.write.bind(process.stderr); // eslint-disable-next-line @typescript-eslint/no-explicit-any process.stderr.write = ((chunk: any) => { captured.push(typeof chunk === "string" ? chunk : String(chunk)); return true; }) as typeof process.stderr.write; try { const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Person"], props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); } finally { process.stderr.write = originalWrite; } const warnLine = captured.find((l) => l.includes("[graph-write] warn reason=missing-provenance")); assert.ok(warnLine, `expected warn line; captured=${JSON.stringify(captured)}`); }); test("writeNodeWithEdges accepts non-action-provenance label writes without Task edge", async () => { const { session } = makeStubSession({ preCheckLabelsByTarget: new Map([["doc-id", ["KnowledgeDocument"]]]), }); const relationships: GraphRelationship[] = [ { type: "ABOUT", direction: "outgoing", targetNodeId: "doc-id" }, ]; const res = await writeNodeWithEdges({ // eslint-disable-next-line @typescript-eslint/no-explicit-any session: session as any, labels: ["Question"], // not in ACTION_PROVENANCE_LABELS props: { accountId: TEST_ACCOUNT_ID }, relationships, createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" }, expectedAccountId: TEST_ACCOUNT_ID, }); assert.equal(res.edgesCreated, 1); });