From dad8e11f1e1981b8c545d8eb04bc6954b32595aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 23:24:32 +0000 Subject: [PATCH] test: harden gateway mocks and env isolation --- src/gateway/test-helpers.mocks.ts | 315 ++++++++++++++++++++++++------ test/test-env.ts | 10 + 2 files changed, 268 insertions(+), 57 deletions(-) diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 36c25897b..a6b933b03 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -1,9 +1,17 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; +import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; + +import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { AgentBinding } from "../config/types.agents.js"; +import type { HooksConfig } from "../config/types.hooks.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type BridgeClientInfo = { nodeId: string; @@ -34,6 +42,136 @@ export type BridgeStartOpts = { >; }; +type StubChannelOptions = { + id: ChannelPlugin["id"]; + label: string; + summary?: Record; +}; + +const createStubOutboundAdapter = (channelId: ChannelPlugin["id"]): ChannelOutboundAdapter => ({ + deliveryMode: "direct", + sendText: async () => ({ + channel: channelId, + messageId: `${channelId}-msg`, + }), + sendMedia: async () => ({ + channel: channelId, + messageId: `${channelId}-msg`, + }), +}); + +const createStubChannelPlugin = (params: StubChannelOptions): ChannelPlugin => ({ + id: params.id, + meta: { + id: params.id, + label: params.label, + selectionLabel: params.label, + docsPath: `/channels/${params.id}`, + blurb: "test stub.", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: () => ({}), + isConfigured: async () => false, + }, + status: { + buildChannelSummary: async () => ({ + configured: false, + ...(params.summary ? params.summary : {}), + }), + }, + outbound: createStubOutboundAdapter(params.id), + messaging: { + normalizeTarget: (raw) => raw, + }, + gateway: { + logoutAccount: async () => ({ + cleared: false, + envToken: false, + loggedOut: false, + }), + }, +}); + +const createStubPluginRegistry = (): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [ + { + pluginId: "whatsapp", + source: "test", + plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }), + }, + { + pluginId: "telegram", + source: "test", + plugin: createStubChannelPlugin({ + id: "telegram", + label: "Telegram", + summary: { tokenSource: "none", lastProbeAt: null }, + }), + }, + { + pluginId: "discord", + source: "test", + plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }), + }, + { + pluginId: "slack", + source: "test", + plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }), + }, + { + pluginId: "signal", + source: "test", + plugin: createStubChannelPlugin({ + id: "signal", + label: "Signal", + summary: { lastProbeAt: null }, + }), + }, + { + pluginId: "imessage", + source: "test", + plugin: createStubChannelPlugin({ id: "imessage", label: "iMessage" }), + }, + { + pluginId: "msteams", + source: "test", + plugin: createStubChannelPlugin({ id: "msteams", label: "Microsoft Teams" }), + }, + { + pluginId: "matrix", + source: "test", + plugin: createStubChannelPlugin({ id: "matrix", label: "Matrix" }), + }, + { + pluginId: "zalo", + source: "test", + plugin: createStubChannelPlugin({ id: "zalo", label: "Zalo" }), + }, + { + pluginId: "zalouser", + source: "test", + plugin: createStubChannelPlugin({ id: "zalouser", label: "Zalo Personal" }), + }, + { + pluginId: "bluebubbles", + source: "test", + plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }), + }, + ], + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + const hoisted = vi.hoisted(() => ({ bridgeStartCalls: [] as BridgeStartOpts[], bridgeInvoke: vi.fn(async () => ({ @@ -70,6 +208,21 @@ const hoisted = vi.hoisted(() => ({ sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), })); +const pluginRegistryState = { + registry: createStubPluginRegistry(), +}; +setActivePluginRegistry(pluginRegistryState.registry); + +export const setTestPluginRegistry = (registry: PluginRegistry) => { + pluginRegistryState.registry = registry; + setActivePluginRegistry(registry); +}; + +export const resetTestPluginRegistry = () => { + pluginRegistryState.registry = createStubPluginRegistry(); + setActivePluginRegistry(pluginRegistryState.registry); +}; + const testConfigRoot = { value: path.join(os.tmpdir(), `clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`), }; @@ -91,7 +244,7 @@ export const agentCommand = hoisted.agentCommand; export const testState = { agentConfig: undefined as Record | undefined, agentsConfig: undefined as Record | undefined, - bindingsConfig: undefined as Array> | undefined, + bindingsConfig: undefined as AgentBinding[] | undefined, channelsConfig: undefined as Record | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | undefined, @@ -100,7 +253,7 @@ export const testState = { cronEnabled: false as boolean | undefined, gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined, gatewayAuth: undefined as Record | undefined, - hooksConfig: undefined as Record | undefined, + hooksConfig: undefined as HooksConfig | undefined, canvasHostPort: undefined as number | undefined, legacyIssues: [] as Array<{ path: string; message: string }>, legacyParsed: {} as Record, @@ -262,61 +415,109 @@ vi.mock("../config/config.js", async () => { changes: testState.migrationChanges, }), loadConfig: () => { - const base = { - agents: (() => { - const defaults = { - model: "anthropic/claude-opus-4-5", - workspace: path.join(os.tmpdir(), "clawd-gateway-test"), - ...testState.agentConfig, - }; - if (testState.agentsConfig) { - return { ...testState.agentsConfig, defaults }; - } - return { defaults }; - })(), - bindings: testState.bindingsConfig, - channels: (() => { - const baseChannels = - testState.channelsConfig && typeof testState.channelsConfig === "object" - ? { ...testState.channelsConfig } - : {}; - const existing = baseChannels.whatsapp; - const mergedWhatsApp: Record = - existing && typeof existing === "object" && !Array.isArray(existing) - ? { ...existing } - : {}; - if (testState.allowFrom !== undefined) { - mergedWhatsApp.allowFrom = testState.allowFrom; - } - baseChannels.whatsapp = mergedWhatsApp; - return baseChannels; - })(), - session: { - mainKey: "main", - store: testState.sessionStorePath, - ...testState.sessionConfig, - }, - gateway: (() => { - const gateway: Record = {}; - if (testState.gatewayBind) gateway.bind = testState.gatewayBind; - if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth; - return Object.keys(gateway).length > 0 ? gateway : undefined; - })(), - canvasHost: (() => { - const canvasHost: Record = {}; - if (typeof testState.canvasHostPort === "number") - canvasHost.port = testState.canvasHostPort; - return Object.keys(canvasHost).length > 0 ? canvasHost : undefined; - })(), - hooks: testState.hooksConfig, - cron: (() => { - const cron: Record = {}; - if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled; - if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath; - return Object.keys(cron).length > 0 ? cron : undefined; - })(), - } as ReturnType; - return applyPluginAutoEnable({ config: base }).config; + const configPath = resolveConfigPath(); + let fileConfig: Record = {}; + try { + if (fsSync.existsSync(configPath)) { + const raw = fsSync.readFileSync(configPath, "utf-8"); + fileConfig = JSON.parse(raw) as Record; + } + } catch { + fileConfig = {}; + } + + const fileAgents = + fileConfig.agents && typeof fileConfig.agents === "object" && !Array.isArray(fileConfig.agents) + ? (fileConfig.agents as Record) + : {}; + const fileDefaults = + fileAgents.defaults && typeof fileAgents.defaults === "object" && !Array.isArray(fileAgents.defaults) + ? (fileAgents.defaults as Record) + : {}; + const defaults = { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + ...fileDefaults, + ...testState.agentConfig, + }; + const agents = testState.agentsConfig + ? { ...fileAgents, ...testState.agentsConfig, defaults } + : { ...fileAgents, defaults }; + + const fileBindings = Array.isArray(fileConfig.bindings) + ? (fileConfig.bindings as AgentBinding[]) + : undefined; + + const fileChannels = + fileConfig.channels && typeof fileConfig.channels === "object" && !Array.isArray(fileConfig.channels) + ? ({ ...(fileConfig.channels as Record) } as Record) + : {}; + const overrideChannels = + testState.channelsConfig && typeof testState.channelsConfig === "object" + ? { ...(testState.channelsConfig as Record) } + : {}; + const mergedChannels = { ...fileChannels, ...overrideChannels }; + if (testState.allowFrom !== undefined) { + const existing = + mergedChannels.whatsapp && typeof mergedChannels.whatsapp === "object" && !Array.isArray(mergedChannels.whatsapp) + ? (mergedChannels.whatsapp as Record) + : {}; + mergedChannels.whatsapp = { + ...existing, + allowFrom: testState.allowFrom, + }; + } + const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined; + + const fileSession = + fileConfig.session && typeof fileConfig.session === "object" && !Array.isArray(fileConfig.session) + ? (fileConfig.session as Record) + : {}; + const session: Record = { + ...fileSession, + mainKey: fileSession.mainKey ?? "main", + }; + if (typeof testState.sessionStorePath === "string") session.store = testState.sessionStorePath; + if (testState.sessionConfig) Object.assign(session, testState.sessionConfig); + + const fileGateway = + fileConfig.gateway && typeof fileConfig.gateway === "object" && !Array.isArray(fileConfig.gateway) + ? ({ ...(fileConfig.gateway as Record) } as Record) + : {}; + if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind; + if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth; + const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; + + const fileCanvasHost = + fileConfig.canvasHost && typeof fileConfig.canvasHost === "object" && !Array.isArray(fileConfig.canvasHost) + ? ({ ...(fileConfig.canvasHost as Record) } as Record) + : {}; + if (typeof testState.canvasHostPort === "number") fileCanvasHost.port = testState.canvasHostPort; + const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined; + + const hooks = + testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined); + + const fileCron = + fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron) + ? ({ ...(fileConfig.cron as Record) } as Record) + : {}; + if (typeof testState.cronEnabled === "boolean") fileCron.enabled = testState.cronEnabled; + if (typeof testState.cronStorePath === "string") fileCron.store = testState.cronStorePath; + const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; + + const config = { + ...fileConfig, + agents, + bindings: testState.bindingsConfig ?? fileBindings, + channels, + session, + gateway, + canvasHost, + hooks, + cron, + }; + return applyPluginAutoEnable({ config, env: process.env }).config; }, parseConfigJson5: (raw: string) => { try { diff --git a/test/test-env.ts b/test/test-env.ts index 172085521..deda32178 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -68,6 +68,11 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { { key: "CLAWDBOT_BRIDGE_PORT", value: process.env.CLAWDBOT_BRIDGE_PORT }, { key: "CLAWDBOT_CANVAS_HOST_PORT", value: process.env.CLAWDBOT_CANVAS_HOST_PORT }, { key: "CLAWDBOT_TEST_HOME", value: process.env.CLAWDBOT_TEST_HOME }, + { key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN }, + { key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN }, + { key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN }, + { key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN }, + { key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN }, { key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN }, { key: "GH_TOKEN", value: process.env.GH_TOKEN }, { key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN }, @@ -91,6 +96,11 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { delete process.env.CLAWDBOT_BRIDGE_PORT; delete process.env.CLAWDBOT_CANVAS_HOST_PORT; // Avoid leaking real GitHub/Copilot tokens into non-live test runs. + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.DISCORD_BOT_TOKEN; + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + delete process.env.SLACK_USER_TOKEN; delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN;