/** * Tests for the gateway-backed threshold reader. * * refreshAutoApproveThreshold(): the cache-bypassing re-read performed * before a permission prompt is surfaced. The reader's caches (5s * conversation TTL with negative caching, 30s global TTL) are never * invalidated by threshold writes, so without the refresh a user who just * switched to Full access could still be prompted from a stale snapshot. * * Channel-permission cell layer: the permission-matrix cell sits between * the per-conversation override (most specific) and the global defaults in * the threshold cascade, with fail-safe transport-failure semantics that * differ between the cached read and the pre-prompt refresh. */ import { beforeEach, describe, expect, mock, test } from "bun:test"; // ── Controllable IPC mock ──────────────────────────────────────────────────── type IpcHandler = (params?: Record) => unknown; const ipcHandlers = new Map(); const ipcCallLog: Array<{ method: string; params?: Record }> = []; mock.module("../ipc/gateway-client.js", () => ({ ipcCall: async (method: string, params?: Record) => { ipcCallLog.push({ method, params }); const handler = ipcHandlers.get(method); return handler ? handler(params) : undefined; }, ipcCallPersistent: async () => undefined, resetPersistentClient: () => {}, })); import { _clearGlobalCacheForTesting, _resetFailureCoalesceForTesting, getAutoApproveThreshold, refreshAutoApproveThreshold, } from "./gateway-threshold-reader.js"; function countCalls(method: string): number { return ipcCallLog.filter((c) => c.method === method).length; } describe("refreshAutoApproveThreshold", () => { beforeEach(() => { _clearGlobalCacheForTesting(); _resetFailureCoalesceForTesting(); ipcHandlers.clear(); ipcCallLog.length = 0; }); test("bypasses a stale conversation cache and returns the fresh override", async () => { // Seed the cache with "low" via the normal read path. ipcHandlers.set("get_conversation_threshold", () => ({ threshold: "low", })); expect(await getAutoApproveThreshold("conv-1", "conversation")).toBe("low"); // The user switches the conversation to Full access — the gateway now // returns "high", but the reader's 5s cache still holds "low". ipcHandlers.set("get_conversation_threshold", () => ({ threshold: "high", })); expect(await getAutoApproveThreshold("conv-1", "conversation")).toBe("low"); // The refresh bypasses the cache and sees the new value. expect(await refreshAutoApproveThreshold("conv-1", "conversation")).toBe( "high", ); }); test("primes the conversation cache so subsequent reads skip IPC", async () => { ipcHandlers.set("get_conversation_threshold", () => ({ threshold: "high", })); expect(await refreshAutoApproveThreshold("conv-2", "conversation")).toBe( "high", ); const callsAfterRefresh = countCalls("get_conversation_threshold"); expect(await getAutoApproveThreshold("conv-2", "conversation")).toBe( "high", ); // Cache hit — no additional IPC round-trip. expect(countCalls("get_conversation_threshold")).toBe(callsAfterRefresh); }); test("returns null when the conversation override read fails", async () => { // No handler registered → ipcCall returns undefined (transport failure). expect( await refreshAutoApproveThreshold("conv-3", "conversation"), ).toBeNull(); // Must not fall through to the global read: without the override we // cannot know whether the conversation is stricter than the global. expect(countCalls("get_global_thresholds")).toBe(0); }); test("falls through to a fresh global read when no override exists", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("get_global_thresholds", () => ({ interactive: "high", autonomous: "low", headless: "none", })); expect(await refreshAutoApproveThreshold("conv-4", "conversation")).toBe( "high", ); }); test("bypasses a stale global cache", async () => { ipcHandlers.set("get_global_thresholds", () => ({ interactive: "medium", autonomous: "low", headless: "none", })); expect(await getAutoApproveThreshold(undefined, "conversation")).toBe( "medium", ); // The user switches the global interactive threshold to Full access — // the reader's 30s global cache still holds "medium". ipcHandlers.set("get_global_thresholds", () => ({ interactive: "high", autonomous: "low", headless: "none", })); expect(await getAutoApproveThreshold(undefined, "conversation")).toBe( "medium", ); expect(await refreshAutoApproveThreshold(undefined, "conversation")).toBe( "high", ); // The refresh primed the global cache with the fresh values. expect(await getAutoApproveThreshold(undefined, "conversation")).toBe( "high", ); }); test("resolves the context-appropriate global field", async () => { ipcHandlers.set("get_global_thresholds", () => ({ interactive: "high", autonomous: "medium", headless: "none", })); expect(await refreshAutoApproveThreshold("conv-5", "background")).toBe( "medium", ); expect(await refreshAutoApproveThreshold("conv-5", "headless")).toBe( "none", ); // Background/headless contexts never consult the per-conversation // override, mirroring getAutoApproveThreshold. expect(countCalls("get_conversation_threshold")).toBe(0); }); test("returns null when the global read fails", async () => { expect( await refreshAutoApproveThreshold(undefined, "conversation"), ).toBeNull(); }); test("returns null for an invalid global value", async () => { ipcHandlers.set("get_global_thresholds", () => ({ interactive: "bogus", autonomous: "low", headless: "none", })); expect( await refreshAutoApproveThreshold(undefined, "conversation"), ).toBeNull(); }); }); // ── Channel-permission cell layer ──────────────────────────────────────────── const CELL_QUERY = { adapter: "slack", channelType: "dm", channelExternalId: "C123", contactType: "trusted_contact", } as const; function setGlobals(interactive: string): void { ipcHandlers.set("get_global_thresholds", () => ({ interactive, autonomous: "low", headless: "none", })); } describe("channel-permission cell layer", () => { beforeEach(() => { _clearGlobalCacheForTesting(); _resetFailureCoalesceForTesting(); ipcHandlers.clear(); ipcCallLog.length = 0; }); test("the conversation override beats the cell", async () => { ipcHandlers.set("get_conversation_threshold", () => ({ threshold: "high", })); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "none", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-c1", "conversation", CELL_QUERY), ).toBe("high"); // The override short-circuits — the cell is never consulted. expect(countCalls("resolve_channel_permission_threshold")).toBe(0); }); test("the cell beats the global default", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "none", scope: "channel" }, })); setGlobals("high"); expect( await getAutoApproveThreshold("conv-c2", "conversation", CELL_QUERY), ).toBe("none"); expect(countCalls("get_global_thresholds")).toBe(0); }); // For non-guardian actors the cell layer is total: a successful walk that // finds no cell resolves the room default — the owner's global interactive // threshold collapsed to the channel's two levels — so a contact's turn // never consumes a raw global threshold. test("no cell resolves the collapsed global for a contact", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("medium"); expect( await getAutoApproveThreshold("conv-c3", "conversation", CELL_QUERY), ).toBe("low"); }); // The collapse keeps `none`: a Strict global yields Strict rooms. test("no cell under a Strict global resolves Strict for a contact", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("none"); expect( await getAutoApproveThreshold("conv-c3s", "conversation", CELL_QUERY), ).toBe("none"); }); // The channel setting governs other people in the room — a guardian query // with no cell keeps falling through to the global thresholds. test("no cell falls through to global for a guardian query", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("medium"); expect( await getAutoApproveThreshold("conv-c3g", "conversation", { ...CELL_QUERY, contactType: "guardian", }), ).toBe("medium"); }); test("the cell governs background and headless contexts, not just conversation", async () => { // Non-interactive guardian sessions (background jobs, headless runs) // read the "background"/"headless" global fields — but a strict channel // cell must still beat them. Gating the cell branch on the conversation // context would reintroduce the guardian background auto-approve bypass. ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "none", scope: "channel" }, })); setGlobals("high"); expect( await getAutoApproveThreshold("conv-bg", "background", CELL_QUERY), ).toBe("none"); expect( await getAutoApproveThreshold(undefined, "headless", CELL_QUERY), ).toBe("none"); expect( await refreshAutoApproveThreshold("conv-bg", "background", CELL_QUERY), ).toBe("none"); }); test("without a cell query the cascade never consults the matrix", async () => { ipcHandlers.set("get_conversation_threshold", () => null); setGlobals("medium"); expect(await getAutoApproveThreshold("conv-c4", "conversation")).toBe( "medium", ); expect(countCalls("resolve_channel_permission_threshold")).toBe(0); }); // An unreadable cell may be a Strict cell, so a contact's read fails // closed rather than consuming the raw global. test("a cell transport failure resolves Strict for a contact on the cached read", async () => { ipcHandlers.set("get_conversation_threshold", () => null); // No resolve handler registered → ipcCall returns undefined. setGlobals("medium"); expect( await getAutoApproveThreshold("conv-c5", "conversation", CELL_QUERY), ).toBe("none"); }); test("a bare-null cell response is a transport failure, not a null-deref or a cached negative", async () => { // The route contract always wraps the resolution ({ resolved: … }); a // bare null is malformed. It must behave exactly like a transport // failure: a contact's hot path resolves Strict, refresh keeps its // prompt, nothing is cached so a real cell is seen as soon as the // response is healthy. ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => null); setGlobals("high"); expect( await getAutoApproveThreshold("conv-c12", "conversation", CELL_QUERY), ).toBe("none"); expect( await refreshAutoApproveThreshold("conv-c12", "conversation", CELL_QUERY), ).toBeNull(); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "none", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-c12", "conversation", CELL_QUERY), ).toBe("none"); }); // An invalid threshold value is never trusted — it degrades to the no-cell // resolution, which for a contact is the room default rather than the // (possibly looser) global threshold. test("an invalid cell threshold value is treated as no cell", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "bogus", scope: "channel" }, })); setGlobals("medium"); expect( await getAutoApproveThreshold("conv-c6", "conversation", CELL_QUERY), ).toBe("low"); }); test("cell resolutions are cached within the TTL, including negatives", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "low", scope: "adapter" }, })); expect( await getAutoApproveThreshold("conv-c7", "conversation", CELL_QUERY), ).toBe("low"); expect( await getAutoApproveThreshold("conv-c7", "conversation", CELL_QUERY), ).toBe("low"); expect(countCalls("resolve_channel_permission_threshold")).toBe(1); // A different coordinate is a different cache entry. The negative // resolution resolves the contact default, not the global threshold. ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("high"); const otherChannel = { ...CELL_QUERY, channelExternalId: "C999" }; expect( await getAutoApproveThreshold("conv-c7", "conversation", otherChannel), ).toBe("low"); expect( await getAutoApproveThreshold("conv-c7", "conversation", otherChannel), ).toBe("low"); expect(countCalls("resolve_channel_permission_threshold")).toBe(2); }); test("transport failures are not cached, so a real cell is seen on retry", async () => { ipcHandlers.set("get_conversation_threshold", () => null); setGlobals("high"); // First read: cell lookup fails — the contact resolves Strict. expect( await getAutoApproveThreshold("conv-c8", "conversation", CELL_QUERY), ).toBe("none"); // The IPC recovers — the next read must see the real cell instead of a // cached failure. The cell's threshold is distinguishable from the // fail-closed value, so this passes only if the retry truly re-reads. ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "low", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-c8", "conversation", CELL_QUERY), ).toBe("low"); }); test("refresh bypasses a stale cell cache and returns the fresh cell", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "low", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-c9", "conversation", CELL_QUERY), ).toBe("low"); // A guardian tightens the cell — the 5s cell cache still holds "low". ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "none", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-c9", "conversation", CELL_QUERY), ).toBe("low"); expect( await refreshAutoApproveThreshold("conv-c9", "conversation", CELL_QUERY), ).toBe("none"); // The refresh primed the cell cache with the fresh value. expect( await getAutoApproveThreshold("conv-c9", "conversation", CELL_QUERY), ).toBe("none"); }); test("refresh returns null on a cell transport failure — never a looser global", async () => { // Failure invariant (see refreshAutoApproveThreshold JSDoc): a transport // failure must never produce a looser outcome than the last successful // read. Scenario guarded here: a Strict ("none") cell computed the // prompt, the cell IPC blips during the refresh, and the global is // "high" — falling through to global would re-evaluate the prompt into // a silent auto-approve. The refresh must return null (caller keeps its // prompt) and must not even attempt the global read. ipcHandlers.set("get_conversation_threshold", () => null); // No resolve_channel_permission_threshold handler → ipcCall returns // undefined, the transport-failure shape (`{ ok: false }` cell result). setGlobals("high"); expect( await refreshAutoApproveThreshold("conv-c10", "conversation", CELL_QUERY), ).toBeNull(); expect(countCalls("get_global_thresholds")).toBe(0); }); // The refresh resolves the same room default the cached read resolves, so // a prompt re-evaluated mid-turn cannot flip to a looser global than the // level the room advertises. test("refresh resolves the collapsed global when no cell exists", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("medium"); expect( await refreshAutoApproveThreshold("conv-c11", "conversation", CELL_QUERY), ).toBe("low"); }); test("refresh falls through to the raw global for a guardian query", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); setGlobals("medium"); expect( await refreshAutoApproveThreshold("conv-c11g", "conversation", { ...CELL_QUERY, contactType: "guardian", }), ).toBe("medium"); }); }); // --------------------------------------------------------------------------- // Two-level collapse of non-guardian channel cells // --------------------------------------------------------------------------- // The channel picker offers two levels; medium/high cells are accepted from // the schema and other writers and behave as low. The collapse lives at the // resolve point so a stored cell can never authorize more than the level the // picker displays for it — in either consumer lane. describe("channel cell two-level collapse", () => { beforeEach(() => { _clearGlobalCacheForTesting(); _resetFailureCoalesceForTesting(); ipcHandlers.clear(); ipcCallLog.length = 0; }); test.each(["medium", "high"] as const)( "a stored %s cell resolves as low for a contact", async (stored) => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: stored, scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-cl1", "conversation", CELL_QUERY), ).toBe("low"); }, ); test.each(["none", "low"] as const)( "a %s cell is untouched by the collapse", async (stored) => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: stored, scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-cl2", "conversation", CELL_QUERY), ).toBe(stored); }, ); // The channel setting governs other people in the room; the guardian's own // lane is out of its scope, so a guardian-contact-type cell keeps its value. test("a guardian-contact-type cell keeps its stored threshold", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "high", scope: "channel" }, })); expect( await getAutoApproveThreshold("conv-cl3", "conversation", { ...CELL_QUERY, contactType: "guardian", }), ).toBe("high"); }); // The cache stores the raw resolution; the collapse is applied on the read // side, so a cache hit collapses identically to a fresh resolve. test("cache hits collapse the same as fresh resolves", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: { threshold: "high", scope: "channel" }, })); const first = await getAutoApproveThreshold( "conv-cl4", "conversation", CELL_QUERY, ); const second = await getAutoApproveThreshold( "conv-cl4", "conversation", CELL_QUERY, ); expect(first).toBe("low"); expect(second).toBe("low"); // One IPC round-trip: the second read came from the cell cache. expect(countCalls("resolve_channel_permission_threshold")).toBe(1); }); }); // --------------------------------------------------------------------------- // Contact fail-closed on cell-read failure; refresh default bypasses caches // --------------------------------------------------------------------------- describe("contact cell layer failure semantics", () => { beforeEach(() => { _clearGlobalCacheForTesting(); _resetFailureCoalesceForTesting(); ipcHandlers.clear(); ipcCallLog.length = 0; }); // An unreadable cell may be a Strict cell — a transient IPC failure must // not hand a contact the raw global instead. test("a failed cell read resolves Strict for a contact, not the global", async () => { ipcHandlers.set("get_conversation_threshold", () => null); // No resolve handler registered → transport failure. setGlobals("high"); expect( await getAutoApproveThreshold("conv-fc1", "conversation", CELL_QUERY), ).toBe("none"); }); test("a failed cell read falls through to global for a guardian query", async () => { ipcHandlers.set("get_conversation_threshold", () => null); setGlobals("high"); expect( await getAutoApproveThreshold("conv-fc2", "conversation", { ...CELL_QUERY, contactType: "guardian", }), ).toBe("high"); }); // The refresh exists to see writes the caches hide — the no-cell default // must read the globals fresh, not the 30s cache. test("refresh derives the no-cell default from fresh globals", async () => { ipcHandlers.set("get_conversation_threshold", () => null); ipcHandlers.set("resolve_channel_permission_threshold", () => ({ resolved: null, })); // Prime the global cache at Strict. setGlobals("none"); expect( await getAutoApproveThreshold("conv-fc3", "conversation", CELL_QUERY), ).toBe("none"); // The owner loosens the global; the cached read still sees Strict, the // refresh must not. setGlobals("medium"); expect( await getAutoApproveThreshold("conv-fc3", "conversation", CELL_QUERY), ).toBe("none"); expect( await refreshAutoApproveThreshold("conv-fc3", "conversation", CELL_QUERY), ).toBe("low"); }); });