test: harden gateway mocks and env isolation

This commit is contained in:
Peter Steinberger
2026-01-18 23:24:32 +00:00
parent 50fdd514ae
commit dad8e11f1e
2 changed files with 268 additions and 57 deletions

View File

@@ -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<string, unknown>;
};
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<string, unknown> | undefined,
agentsConfig: undefined as Record<string, unknown> | undefined,
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
bindingsConfig: undefined as AgentBinding[] | undefined,
channelsConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | 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<string, unknown> | undefined,
hooksConfig: undefined as Record<string, unknown> | undefined,
hooksConfig: undefined as HooksConfig | undefined,
canvasHostPort: undefined as number | undefined,
legacyIssues: [] as Array<{ path: string; message: string }>,
legacyParsed: {} as Record<string, unknown>,
@@ -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<string, unknown> =
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<string, unknown> = {};
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<string, unknown> = {};
if (typeof testState.canvasHostPort === "number")
canvasHost.port = testState.canvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testState.hooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
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<typeof actual.loadConfig>;
return applyPluginAutoEnable({ config: base }).config;
const configPath = resolveConfigPath();
let fileConfig: Record<string, unknown> = {};
try {
if (fsSync.existsSync(configPath)) {
const raw = fsSync.readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(raw) as Record<string, unknown>;
}
} catch {
fileConfig = {};
}
const fileAgents =
fileConfig.agents && typeof fileConfig.agents === "object" && !Array.isArray(fileConfig.agents)
? (fileConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults && typeof fileAgents.defaults === "object" && !Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
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<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...(testState.channelsConfig as Record<string, unknown>) }
: {};
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp && typeof mergedChannels.whatsapp === "object" && !Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
const session: Record<string, unknown> = {
...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<string, unknown>) } as Record<string, unknown>)
: {};
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<string, unknown>) } as Record<string, unknown>)
: {};
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<string, unknown>) } as Record<string, unknown>)
: {};
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 {

View File

@@ -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;