/** * Tests for the trusted-contact pending-approval requester notification. * * Verifies that: * 1. Trusted contacts receive a one-shot "waiting for guardian approval" message * 2. The message mentions the guardian by name when available * 3. Messages are deduplicated by requestId (no repeated spam) * 4. Guardian and unknown actors do NOT receive the notification * 5. Delivery failures allow retry on next poll */ import { beforeEach, describe, expect, mock, test } from "bun:test"; // ── Platform mock ── // ── Logger mock ── // eslint-disable-next-line @typescript-eslint/no-require-imports const realLogger = require("../util/logger.js"); mock.module("../util/logger.js", () => ({ ...realLogger, getLogger: () => new Proxy({} as Record, { get: () => () => {}, }), truncateForLog: (value: string) => value, })); // ── Notification signal mock ── mock.module("../notifications/emit-signal.js", () => ({ emitNotificationSignal: async () => ({ signalId: "test-signal", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [], }), })); // ── Gateway client mock ── // Track all deliverChannelReply calls for assertions const deliveredReplies: Array<{ url: string; payload: Record; }> = []; let deliverShouldFail = false; mock.module("../runtime/gateway-client.js", () => ({ deliverChannelReply: async ( url: string, payload: Record, ) => { if (deliverShouldFail) { throw new Error("Delivery failed"); } deliveredReplies.push({ url, payload }); return { ok: true }; }, })); // ── Guardian binding mock ── // mockGuardianDelivery controls what the gateway guardian-delivery reader // returns for the source channel; non-null carries at least a displayName. let mockGuardianDelivery: { displayName: string } | null = null; mock.module("../runtime/channel-verification-service.js", () => ({ getGuardianBinding: () => null, // Re-export stubs for other functions to prevent import errors bindSessionIdentity: () => {}, createOutboundSession: () => ({}), findActiveSession: () => null, getGuardianBindingForChannel: () => null, getPendingSession: () => null, isGuardian: () => false, resolveBootstrapToken: () => null, updateSessionDelivery: () => {}, updateSessionStatus: () => {}, validateAndConsumeVerification: () => ({ success: false, reason: "no_challenge", }), })); // ── Guardian delivery reader mock ── mock.module("../contacts/guardian-delivery-reader.js", () => ({ getGuardianDelivery: async (input?: { channelTypes?: string[] }) => { if (!mockGuardianDelivery) { return []; } const channelType = input?.channelTypes?.[0] ?? "telegram"; return [{ channelType, status: "active", ...mockGuardianDelivery }]; }, guardianForChannel: ( list: Array<{ channelType: string; status: string }>, channelType: string, ) => list.find((g) => g.channelType === channelType && g.status === "active"), })); // ── Pending interactions mock ── let mockPendingApprovals: Array<{ requestId: string; toolName: string; input: Record; riskLevel: string; }> = []; mock.module("../runtime/channel-approvals.js", () => ({ getApprovalInfoByConversation: () => mockPendingApprovals, getChannelApprovalPrompt: () => null, buildApprovalUIMetadata: () => ({}), })); // ── Config env mock ── mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true, getGatewayInternalBaseUrl: () => "http://localhost:3000", })); // ── User reference mock ── // eslint-disable-next-line @typescript-eslint/no-require-imports const realUserReference = require("../prompts/user-reference.js"); mock.module("../prompts/user-reference.js", () => ({ ...realUserReference, resolveUserReference: () => "my human", resolveGuardianName: (guardianDisplayName?: string | null): string => { // Mirror the real implementation: guardian persona name (from users/.md) > guardianDisplayName > default const userRef = "my human"; // In tests, resolveUserReference() returns this if (userRef !== "my human") { return userRef; } if (guardianDisplayName && guardianDisplayName.trim().length > 0) { return guardianDisplayName.trim(); } return "my human"; }, })); // Import module under test AFTER mocks are set up import type { ChannelId } from "../channels/types.js"; import { getGuardianDelivery, guardianForChannel, } from "../contacts/guardian-delivery-reader.js"; import type { TrustContext } from "../daemon/trust-context-types.js"; import { resolveGuardianName } from "../prompts/user-reference.js"; // We need to test the private functions by importing the module. // Since startTrustedContactApprovalNotifier is not exported, we test it // indirectly through handleChannelInbound via processChannelMessageInBackground. // // However, to test the notifier function in isolation, we extract the // logic into a helper that we can call directly. // For unit testing, we replicate the core logic here to verify behavior. // The integration is tested by verifying deliverChannelReply calls. // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Simulates the core logic of the trusted-contact approval notifier. * This mirrors the implementation in inbound-message-handler.ts. * * Uses a Map for deduplication so that cleanup * is scoped to the owning conversation — concurrent pollers for different * conversations will not evict each other's entries. */ async function simulateNotifierPoll(params: { conversationId: string; sourceChannel: ChannelId; externalChatId: string; trustClass: TrustContext["trustClass"]; guardianExternalUserId?: string; replyCallbackUrl: string; assistantId?: string; notifiedRequestIds: Map; }): Promise { const { conversationId, trustClass, guardianExternalUserId, notifiedRequestIds, } = params; // Gate check: only identity-known non-guardian contacts with guardian route const isIdentityKnownNonGuardian = trustClass === "trusted_contact" || trustClass === "unverified_contact"; if (!isIdentityKnownNonGuardian || !guardianExternalUserId) { return false; } const { getApprovalInfoByConversation } = await import("../runtime/channel-approvals.js"); const { deliverChannelReply } = await import("../runtime/gateway-client.js"); const pending = getApprovalInfoByConversation(params.conversationId); const info = pending[0]; // Clean up resolved requests — only for THIS conversation's entries. const currentPendingIds = new Set(pending.map((p) => p.requestId)); for (const [rid, cid] of notifiedRequestIds) { if (cid === conversationId && !currentPendingIds.has(rid)) { notifiedRequestIds.delete(rid); } } if (!info || notifiedRequestIds.has(info.requestId)) { return false; } notifiedRequestIds.set(info.requestId, conversationId); // Resolve guardian name via the gateway guardian-delivery reader const guardians = await getGuardianDelivery({ channelTypes: [params.sourceChannel], }); const guardian = guardians ? guardianForChannel(guardians, params.sourceChannel) : undefined; const guardianName = resolveGuardianName(guardian?.displayName ?? undefined); const waitingText = `Waiting for ${guardianName}'s approval...`; try { await deliverChannelReply(params.replyCallbackUrl, { chatId: params.externalChatId, text: waitingText, assistantId: params.assistantId ?? "self", }); return true; } catch { notifiedRequestIds.delete(info.requestId); return false; } } // =========================================================================== // TESTS // =========================================================================== describe("trusted-contact pending-approval notifier", () => { beforeEach(() => { deliveredReplies.length = 0; deliverShouldFail = false; mockPendingApprovals = []; mockGuardianDelivery = null; }); test("sends waiting message to trusted contact when pending approval exists", async () => { mockPendingApprovals = [ { requestId: "req-1", toolName: "bash", input: { command: "ls" }, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: "Mom" }; const notified = new Map(); const sent = await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sent).toBe(true); expect(deliveredReplies).toHaveLength(1); expect(deliveredReplies[0].payload.text).toBe( "Waiting for Mom's approval...", ); expect(deliveredReplies[0].payload.chatId).toBe("chat-123"); expect(notified.has("req-1")).toBe(true); }); test("uses contact displayName from contact store", async () => { mockPendingApprovals = [ { requestId: "req-2", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: "Guardian User" }; const notified = new Map(); await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(deliveredReplies).toHaveLength(1); expect(deliveredReplies[0].payload.text).toBe( "Waiting for Guardian User's approval...", ); }); test("falls back to user reference when guardian has empty display name", async () => { mockPendingApprovals = [ { requestId: "req-3", toolName: "bash", input: {}, riskLevel: "medium", }, ]; // Guardian contact exists but has an empty displayName mockGuardianDelivery = { displayName: "" }; const notified = new Map(); await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(deliveredReplies).toHaveLength(1); expect(deliveredReplies[0].payload.text).toBe( "Waiting for my human's approval...", ); }); test("falls back to user reference when no guardian contact exists", async () => { mockPendingApprovals = [ { requestId: "req-4", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = null; const notified = new Map(); await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(deliveredReplies).toHaveLength(1); expect(deliveredReplies[0].payload.text).toBe( "Waiting for my human's approval...", ); }); test("deduplicates by requestId — does not send twice for same request", async () => { mockPendingApprovals = [ { requestId: "req-5", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: "Guardian" }; const notified = new Map(); const baseParams = { conversationId: "conv-1", sourceChannel: "telegram" as ChannelId, externalChatId: "chat-123", trustClass: "trusted_contact" as const, guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }; // First poll: should send const sent1 = await simulateNotifierPoll(baseParams); expect(sent1).toBe(true); expect(deliveredReplies).toHaveLength(1); // Second poll: same requestId, should NOT send const sent2 = await simulateNotifierPoll(baseParams); expect(sent2).toBe(false); expect(deliveredReplies).toHaveLength(1); // Still just 1 }); test("sends separate messages for different requestIds", async () => { mockGuardianDelivery = { displayName: "Guardian" }; const notified = new Map(); const baseParams = { conversationId: "conv-1", sourceChannel: "telegram" as ChannelId, externalChatId: "chat-123", trustClass: "trusted_contact" as const, guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }; // First request mockPendingApprovals = [ { requestId: "req-A", toolName: "bash", input: {}, riskLevel: "medium", }, ]; await simulateNotifierPoll(baseParams); expect(deliveredReplies).toHaveLength(1); // Second request (different requestId) mockPendingApprovals = [ { requestId: "req-B", toolName: "read_file", input: {}, riskLevel: "low", }, ]; await simulateNotifierPoll(baseParams); expect(deliveredReplies).toHaveLength(2); }); test("concurrent pollers for different conversations do not evict each other", async () => { mockGuardianDelivery = { displayName: "Guardian" }; // Shared dedupe map simulating the module-level global const notified = new Map(); // Conversation A gets a pending approval and notifies mockPendingApprovals = [ { requestId: "req-convA", toolName: "bash", input: {}, riskLevel: "medium", }, ]; const sentA = await simulateNotifierPoll({ conversationId: "conv-A", sourceChannel: "telegram", externalChatId: "chat-A", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sentA).toBe(true); expect(deliveredReplies).toHaveLength(1); // Conversation B polls with no pending approvals — its cleanup must // NOT evict conv-A's entry from the shared map. mockPendingApprovals = []; await simulateNotifierPoll({ conversationId: "conv-B", sourceChannel: "telegram", externalChatId: "chat-B", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); // req-convA should still be in the notified map (not evicted by conv-B) expect(notified.has("req-convA")).toBe(true); // Re-poll conversation A with the same pending approval — should NOT // re-send because the entry was preserved. mockPendingApprovals = [ { requestId: "req-convA", toolName: "bash", input: {}, riskLevel: "medium", }, ]; const sentA2 = await simulateNotifierPoll({ conversationId: "conv-A", sourceChannel: "telegram", externalChatId: "chat-A", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sentA2).toBe(false); expect(deliveredReplies).toHaveLength(1); // Still just 1 — no duplicate }); test("does not activate for guardian actors", async () => { mockPendingApprovals = [ { requestId: "req-6", toolName: "bash", input: {}, riskLevel: "medium", }, ]; const notified = new Map(); const sent = await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "guardian", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sent).toBe(false); expect(deliveredReplies).toHaveLength(0); }); test("does not activate for unknown actors", async () => { mockPendingApprovals = [ { requestId: "req-7", toolName: "bash", input: {}, riskLevel: "medium", }, ]; const notified = new Map(); const sent = await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "unknown", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sent).toBe(false); expect(deliveredReplies).toHaveLength(0); }); test("does not activate for trusted contact without guardian identity", async () => { mockPendingApprovals = [ { requestId: "req-8", toolName: "bash", input: {}, riskLevel: "medium", }, ]; const notified = new Map(); const sent = await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: undefined, replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sent).toBe(false); expect(deliveredReplies).toHaveLength(0); }); test("retries delivery on failure — removes requestId from notified set", async () => { mockPendingApprovals = [ { requestId: "req-9", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: "Guardian" }; const notified = new Map(); const baseParams = { conversationId: "conv-1", sourceChannel: "telegram" as ChannelId, externalChatId: "chat-123", trustClass: "trusted_contact" as const, guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }; // First attempt: delivery fails deliverShouldFail = true; const sent1 = await simulateNotifierPoll(baseParams); expect(sent1).toBe(false); expect(notified.has("req-9")).toBe(false); // Removed for retry // Second attempt: delivery succeeds deliverShouldFail = false; const sent2 = await simulateNotifierPoll(baseParams); expect(sent2).toBe(true); expect(deliveredReplies).toHaveLength(1); expect(notified.has("req-9")).toBe(true); }); test("does not send when no pending approvals exist", async () => { mockPendingApprovals = []; const notified = new Map(); const sent = await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(sent).toBe(false); expect(deliveredReplies).toHaveLength(0); }); test("uses contact displayName from guardian contact record", async () => { mockPendingApprovals = [ { requestId: "req-10", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: "Sarah" }; const notified = new Map(); await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(deliveredReplies).toHaveLength(1); expect(deliveredReplies[0].payload.text).toBe( "Waiting for Sarah's approval...", ); }); test("falls back to default when guardian contact has whitespace-only displayName", async () => { mockPendingApprovals = [ { requestId: "req-11", toolName: "bash", input: {}, riskLevel: "medium", }, ]; mockGuardianDelivery = { displayName: " " }; const notified = new Map(); await simulateNotifierPoll({ conversationId: "conv-1", sourceChannel: "telegram", externalChatId: "chat-123", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", replyCallbackUrl: "http://localhost:3000/deliver/telegram", notifiedRequestIds: notified, }); expect(deliveredReplies).toHaveLength(1); // Falls back to default user reference expect(deliveredReplies[0].payload.text).toBe( "Waiting for my human's approval...", ); }); });