diff --git a/src/channels/plugins/contracts/dm-policy.contract.test.ts b/src/channels/plugins/contracts/dm-policy.contract.test.ts index b82bc960759..a093d5d0b01 100644 --- a/src/channels/plugins/contracts/dm-policy.contract.test.ts +++ b/src/channels/plugins/contracts/dm-policy.contract.test.ts @@ -19,7 +19,7 @@ const signalSender: SignalSender = { e164: "+15550001111", }; -const cases: ChannelSmokeCase[] = [ +const channelSmokeCases: ChannelSmokeCase[] = [ { name: "bluebubbles", storeAllowFrom: ["attacker-user"], @@ -47,23 +47,41 @@ const cases: ChannelSmokeCase[] = [ }, ]; +function expandChannelIngressCases(cases: readonly ChannelSmokeCase[]) { + return cases.flatMap((testCase) => + (["message", "reaction"] as const).map((ingress) => ({ + testCase, + ingress, + })), + ); +} + describe("security/dm-policy-shared channel smoke", () => { - for (const testCase of cases) { - for (const ingress of ["message", "reaction"] as const) { - it(`[${testCase.name}] blocks group ${ingress} when sender is only in pairing store`, () => { - const access = resolveDmGroupAccessWithLists({ - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "allowlist", - allowFrom: ["owner-user"], - groupAllowFrom: ["group-owner"], - storeAllowFrom: testCase.storeAllowFrom, - isSenderAllowed: testCase.isSenderAllowed, - }); - expect(access.decision).toBe("block"); - expect(access.reasonCode).toBe(DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); - expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); - }); - } + function expectBlockedGroupAccess(params: { + storeAllowFrom: string[]; + isSenderAllowed: (allowFrom: string[]) => boolean; + }) { + const access = resolveDmGroupAccessWithLists({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner-user"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: params.storeAllowFrom, + isSenderAllowed: params.isSenderAllowed, + }); + expect(access.decision).toBe("block"); + expect(access.reasonCode).toBe(DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); + expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); } + + it.each(expandChannelIngressCases(channelSmokeCases))( + "[$testCase.name] blocks group $ingress when sender is only in pairing store", + ({ testCase }) => { + expectBlockedGroupAccess({ + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, + }); + }, + ); }); diff --git a/src/channels/plugins/contracts/group-policy.contract.test.ts b/src/channels/plugins/contracts/group-policy.contract.test.ts index c20b93c9c63..ecffc39d81a 100644 --- a/src/channels/plugins/contracts/group-policy.contract.test.ts +++ b/src/channels/plugins/contracts/group-policy.contract.test.ts @@ -11,6 +11,45 @@ import { import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js"; describe("channel runtime group policy contract", () => { + type ResolvedGroupPolicy = ReturnType; + + function expectResolvedGroupPolicyCase( + resolved: Pick, + expected: Pick, + ) { + expect(resolved.groupPolicy).toBe(expected.groupPolicy); + expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied); + } + + function expectAllowedZaloGroupAccess(params: Parameters[0]) { + expect(evaluateZaloGroupAccess(params)).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + } + + function expectResolvedDiscordGroupPolicyCase(params: { + providerConfigPresent: Parameters< + typeof resolveDiscordRuntimeGroupPolicy + >[0]["providerConfigPresent"]; + groupPolicy: Parameters[0]["groupPolicy"]; + expected: Pick; + }) { + expectResolvedGroupPolicyCase(resolveDiscordRuntimeGroupPolicy(params), params.expected); + } + + function expectAllowedZaloGroupAccessCase( + params: Omit[0], "groupAllowFrom"> & { + groupAllowFrom: readonly string[]; + }, + ) { + expectAllowedZaloGroupAccess({ + ...params, + groupAllowFrom: [...params.groupAllowFrom], + }); + } + describe("slack", () => { installChannelRuntimeGroupPolicyFallbackSuite({ resolve: resolveSlackRuntimeGroupPolicy, @@ -60,13 +99,17 @@ describe("channel runtime group policy contract", () => { missingDefaultLabel: "ignores explicit global defaults when provider config is missing", }); - it("respects explicit provider policy", () => { - const resolved = resolveDiscordRuntimeGroupPolicy({ + it.each([ + { providerConfigPresent: false, groupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("disabled"); - expect(resolved.providerMissingFallbackApplied).toBe(false); + expected: { + groupPolicy: "disabled", + providerMissingFallbackApplied: false, + }, + }, + ] as const)("respects explicit provider policy %#", (testCase) => { + expectResolvedDiscordGroupPolicyCase(testCase); }); }); @@ -79,19 +122,16 @@ describe("channel runtime group policy contract", () => { missingDefaultLabel: "ignores explicit global defaults when provider config is missing", }); - it("keeps provider-owned group access evaluation", () => { - const decision = evaluateZaloGroupAccess({ + it.each([ + { providerConfigPresent: true, configuredGroupPolicy: "allowlist", defaultGroupPolicy: "open", groupAllowFrom: ["zl:12345"], senderId: "12345", - }); - expect(decision).toMatchObject({ - allowed: true, - groupPolicy: "allowlist", - reason: "allowed", - }); + }, + ] as const)("keeps provider-owned group access evaluation %#", (testCase) => { + expectAllowedZaloGroupAccessCase(testCase); }); }); }); diff --git a/src/channels/plugins/contracts/plugins-core.contract.test.ts b/src/channels/plugins/contracts/plugins-core.contract.test.ts index 01b0a51b0c3..be8583874b6 100644 --- a/src/channels/plugins/contracts/plugins-core.contract.test.ts +++ b/src/channels/plugins/contracts/plugins-core.contract.test.ts @@ -49,227 +49,129 @@ describe("channel plugin registry", () => { setActivePluginRegistry(emptyRegistry); }); + function expectListedChannelPluginIds(expectedIds: string[]) { + expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(expectedIds); + } + + function expectRegistryActivationCase(run: () => void) { + run(); + } + afterEach(() => { setActivePluginRegistry(emptyRegistry); clearPluginDiscoveryCache(); clearPluginManifestRegistryCache(); }); - it("sorts channel plugins by configured order", () => { - const orderedPlugins: Array<[string, number]> = [ - ["demo-middle", 20], - ["demo-first", 10], - ["demo-last", 30], - ]; - const registry = createTestRegistry( - orderedPlugins.map(([id, order]) => ({ - pluginId: id, - plugin: createPlugin(id, order), - source: "test", - })), - ); - setActivePluginRegistry(registry); - const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["demo-first", "demo-middle", "demo-last"]); - }); - - it("refreshes cached channel lookups when the same registry instance is re-activated", () => { - const registry = createTestRegistry([ - { - pluginId: "demo-alpha", - plugin: createPlugin("demo-alpha"), - source: "test", + it.each([ + { + name: "sorts channel plugins by configured order", + run: () => { + const orderedPlugins: Array<[string, number]> = [ + ["demo-middle", 20], + ["demo-first", 10], + ["demo-last", 30], + ]; + const registry = createTestRegistry( + orderedPlugins.map(([id, order]) => ({ + pluginId: id, + plugin: createPlugin(id, order), + source: "test", + })), + ); + setActivePluginRegistry(registry); + expectListedChannelPluginIds(["demo-first", "demo-middle", "demo-last"]); }, - ]); - setActivePluginRegistry(registry, "registry-test"); - expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["demo-alpha"]); + }, + { + name: "refreshes cached channel lookups when the same registry instance is re-activated", + run: () => { + const registry = createTestRegistry([ + { + pluginId: "demo-alpha", + plugin: createPlugin("demo-alpha"), + source: "test", + }, + ]); + setActivePluginRegistry(registry, "registry-test"); + expectListedChannelPluginIds(["demo-alpha"]); - registry.channels = [ - { - pluginId: "demo-beta", - plugin: createPlugin("demo-beta"), - source: "test", + registry.channels = [ + { + pluginId: "demo-beta", + plugin: createPlugin("demo-beta"), + source: "test", + }, + ] as typeof registry.channels; + setActivePluginRegistry(registry, "registry-test"); + + expectListedChannelPluginIds(["demo-beta"]); }, - ] as typeof registry.channels; - setActivePluginRegistry(registry, "registry-test"); - - expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["demo-beta"]); + }, + ] as const)("$name", ({ run }) => { + expectRegistryActivationCase(run); }); }); describe("channel plugin catalog", () => { - it("includes external catalog entries", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/demo-channel", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo entry", - order: 999, - }, - install: { - npmSpec: "@openclaw/demo-channel", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( - (entry) => entry.id, - ); - expect(ids).toContain("demo-channel"); - }); - - it("preserves plugin ids when they differ from channel ids", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-")); - const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "@vendor/demo-channel-plugin", - openclaw: { - extensions: ["./index.js"], - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo channel", - }, - install: { - npmSpec: "@vendor/demo-channel-plugin", - }, + function createCatalogEntry(params: { + packageName: string; + channelId: string; + label: string; + blurb: string; + order?: number; + }) { + return { + name: params.packageName, + openclaw: { + channel: { + id: params.channelId, + label: params.label, + selectionLabel: params.label, + docsPath: `/channels/${params.channelId}`, + blurb: params.blurb, + ...(params.order === undefined ? {} : { order: params.order }), + }, + install: { + npmSpec: params.packageName, }, - }), - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "@vendor/demo-runtime", - configSchema: {}, - }), - ); - fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf-8"); - - const entry = listChannelPluginCatalogEntries({ - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", }, - }).find((item) => item.id === "demo-channel"); + }; + } - expect(entry?.pluginId).toBe("@vendor/demo-runtime"); - }); - - it("uses the provided env for external catalog path resolution", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-")); - const catalogPath = path.join(home, "catalog.json"); + function writeCatalogFile(catalogPath: string, entry: Record) { fs.writeFileSync( catalogPath, JSON.stringify({ - entries: [ - { - name: "@openclaw/env-demo-channel", - openclaw: { - channel: { - id: "env-demo-channel", - label: "Env Demo Channel", - selectionLabel: "Env Demo Channel", - docsPath: "/channels/env-demo-channel", - blurb: "Env demo entry", - order: 1000, - }, - install: { - npmSpec: "@openclaw/env-demo-channel", - }, - }, - }, - ], + entries: [entry], }), ); + } - const ids = listChannelPluginCatalogEntries({ - env: { - ...process.env, - OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json", - OPENCLAW_HOME: home, - HOME: home, - }, - }).map((entry) => entry.id); - - expect(ids).toContain("env-demo-channel"); - }); - - it("uses the provided env for default catalog paths", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); - const catalogPath = path.join(stateDir, "plugins", "catalog.json"); - fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/default-env-demo", - openclaw: { - channel: { - id: "default-env-demo", - label: "Default Env Demo", - selectionLabel: "Default Env Demo", - docsPath: "/channels/default-env-demo", - blurb: "Default env demo entry", - }, - install: { - npmSpec: "@openclaw/default-env-demo", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - }, - }).map((entry) => entry.id); - - expect(ids).toContain("default-env-demo"); - }); - - it("keeps discovered plugins ahead of external catalog overrides", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); - const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin"); - const catalogPath = path.join(stateDir, "catalog.json"); + function writeDiscoveredChannelPlugin(params: { + stateDir: string; + packageName: string; + channelLabel: string; + pluginId: string; + blurb: string; + }) { + const pluginDir = path.join(params.stateDir, "extensions", "demo-channel-plugin"); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync( path.join(pluginDir, "package.json"), JSON.stringify({ - name: "@vendor/demo-channel-plugin", + name: params.packageName, openclaw: { extensions: ["./index.js"], channel: { id: "demo-channel", - label: "Demo Channel Runtime", - selectionLabel: "Demo Channel Runtime", + label: params.channelLabel, + selectionLabel: params.channelLabel, docsPath: "/channels/demo-channel", - blurb: "discovered plugin", + blurb: params.blurb, }, install: { - npmSpec: "@vendor/demo-channel-plugin", + npmSpec: params.packageName, }, }, }), @@ -278,49 +180,201 @@ describe("channel plugin catalog", () => { fs.writeFileSync( path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify({ - id: "@vendor/demo-channel-runtime", + id: params.pluginId, configSchema: {}, }), "utf8", ); fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@vendor/demo-channel-catalog", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel Catalog", - selectionLabel: "Demo Channel Catalog", - docsPath: "/channels/demo-channel", - blurb: "external catalog", - }, - install: { - npmSpec: "@vendor/demo-channel-catalog", - }, - }, - }, - ], + return pluginDir; + } + + function expectCatalogIdsContain(params: { + expectedId: string; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; + }) { + const ids = listChannelPluginCatalogEntries({ + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), + }).map((entry) => entry.id); + expect(ids).toContain(params.expectedId); + } + + function findCatalogEntry(params: { + channelId: string; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; + }) { + return listChannelPluginCatalogEntries({ + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), + }).find((entry) => entry.id === params.channelId); + } + + function expectCatalogEntryMatch(params: { + channelId: string; + expected: Record; + catalogPaths?: string[]; + env?: NodeJS.ProcessEnv; + }) { + expect( + findCatalogEntry({ + channelId: params.channelId, + ...(params.catalogPaths ? { catalogPaths: params.catalogPaths } : {}), + ...(params.env ? { env: params.env } : {}), }), - "utf8", - ); + ).toMatchObject(params.expected); + } - const entry = listChannelPluginCatalogEntries({ - catalogPaths: [catalogPath], - env: { - ...process.env, - OPENCLAW_STATE_DIR: stateDir, - CLAWDBOT_STATE_DIR: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + it.each([ + { + name: "includes external catalog entries", + setup: () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/demo-channel", + channelId: "demo-channel", + label: "Demo Channel", + blurb: "Demo entry", + order: 999, + }), + ); + return { + channelId: "demo-channel", + catalogPaths: [catalogPath], + expected: { id: "demo-channel" }, + }; }, - }).find((item) => item.id === "demo-channel"); + }, + { + name: "preserves plugin ids when they differ from channel ids", + setup: () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-")); + writeDiscoveredChannelPlugin({ + stateDir, + packageName: "@vendor/demo-channel-plugin", + channelLabel: "Demo Channel", + pluginId: "@vendor/demo-runtime", + blurb: "Demo channel", + }); + return { + channelId: "demo-channel", + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + expected: { pluginId: "@vendor/demo-runtime" }, + }; + }, + }, + { + name: "keeps discovered plugins ahead of external catalog overrides", + setup: () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); + const catalogPath = path.join(stateDir, "catalog.json"); + writeDiscoveredChannelPlugin({ + stateDir, + packageName: "@vendor/demo-channel-plugin", + channelLabel: "Demo Channel Runtime", + pluginId: "@vendor/demo-channel-runtime", + blurb: "discovered plugin", + }); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@vendor/demo-channel-catalog", + channelId: "demo-channel", + label: "Demo Channel Catalog", + blurb: "external catalog", + }), + ); + return { + channelId: "demo-channel", + catalogPaths: [catalogPath], + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + expected: { + install: { npmSpec: "@vendor/demo-channel-plugin" }, + meta: { label: "Demo Channel Runtime" }, + pluginId: "@vendor/demo-channel-runtime", + }, + }; + }, + }, + ] as const)("$name", ({ setup }) => { + const setupResult = setup(); + const { channelId, expected } = setupResult; + expectCatalogEntryMatch({ + channelId, + expected, + ...("catalogPaths" in setupResult ? { catalogPaths: setupResult.catalogPaths } : {}), + ...("env" in setupResult ? { env: setupResult.env } : {}), + }); + }); - expect(entry?.install.npmSpec).toBe("@vendor/demo-channel-plugin"); - expect(entry?.meta.label).toBe("Demo Channel Runtime"); - expect(entry?.pluginId).toBe("@vendor/demo-channel-runtime"); + it.each([ + { + name: "uses the provided env for external catalog path resolution", + setup: () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-")); + const catalogPath = path.join(home, "catalog.json"); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/env-demo-channel", + channelId: "env-demo-channel", + label: "Env Demo Channel", + blurb: "Env demo entry", + order: 1000, + }), + ); + return { + env: { + ...process.env, + OPENCLAW_PLUGIN_CATALOG_PATHS: "~/catalog.json", + OPENCLAW_HOME: home, + HOME: home, + }, + expectedId: "env-demo-channel", + }; + }, + }, + { + name: "uses the provided env for default catalog paths", + setup: () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); + const catalogPath = path.join(stateDir, "plugins", "catalog.json"); + fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); + writeCatalogFile( + catalogPath, + createCatalogEntry({ + packageName: "@openclaw/default-env-demo", + channelId: "default-env-demo", + label: "Default Env Demo", + blurb: "Default env demo entry", + }), + ); + return { + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + }, + expectedId: "default-env-demo", + }; + }, + }, + ] as const)("$name", ({ setup }) => { + const { env, expectedId } = setup(); + expectCatalogIdsContain({ env, expectedId }); }); }); @@ -393,6 +447,42 @@ function makeDemoConfigWritesCfg(accountIdKey: string) { } describe("channel plugin loader", () => { + async function expectLoadedPluginCase(params: { + registry: Parameters[0]; + expectedPlugin: ChannelPlugin; + }) { + setActivePluginRegistry(params.registry); + expect(await loadChannelPlugin("demo-loader")).toBe(params.expectedPlugin); + } + + async function expectLoadedOutboundCase(params: { + registry: Parameters[0]; + expectedOutbound: ChannelOutboundAdapter | undefined; + }) { + setActivePluginRegistry(params.registry); + expect(await loadChannelOutboundAdapter("demo-loader")).toBe(params.expectedOutbound); + } + + async function expectReloadedLoaderCase(params: { + load: typeof loadChannelPlugin | typeof loadChannelOutboundAdapter; + firstRegistry: Parameters[0]; + secondRegistry: Parameters[0]; + firstExpected: ChannelPlugin | ChannelOutboundAdapter | undefined; + secondExpected: ChannelPlugin | ChannelOutboundAdapter | undefined; + }) { + setActivePluginRegistry(params.firstRegistry); + expect(await params.load("demo-loader")).toBe(params.firstExpected); + setActivePluginRegistry(params.secondRegistry); + expect(await params.load("demo-loader")).toBe(params.secondExpected); + } + + async function expectOutboundAdapterMissingCase( + registry: Parameters[0], + ) { + setActivePluginRegistry(registry); + expect(await loadChannelOutboundAdapter("demo-loader")).toBeUndefined(); + } + beforeEach(() => { setActivePluginRegistry(emptyRegistry); }); @@ -403,61 +493,124 @@ describe("channel plugin loader", () => { clearPluginManifestRegistryCache(); }); - it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithDemoLoader); - const plugin = await loadChannelPlugin("demo-loader"); - expect(plugin).toBe(demoLoaderPlugin); - }); - - it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithDemoLoader); - const outbound = await loadChannelOutboundAdapter("demo-loader"); - expect(outbound).toBe(demoOutbound); - }); - - it("refreshes cached plugin values when registry changes", async () => { - setActivePluginRegistry(registryWithDemoLoader); - expect(await loadChannelPlugin("demo-loader")).toBe(demoLoaderPlugin); - setActivePluginRegistry(registryWithDemoLoaderV2); - expect(await loadChannelPlugin("demo-loader")).toBe(demoLoaderPluginV2); - }); - - it("refreshes cached outbound values when registry changes", async () => { - setActivePluginRegistry(registryWithDemoLoader); - expect(await loadChannelOutboundAdapter("demo-loader")).toBe(demoOutbound); - setActivePluginRegistry(registryWithDemoLoaderV2); - expect(await loadChannelOutboundAdapter("demo-loader")).toBe(demoOutboundV2); - }); - - it("returns undefined when plugin has no outbound adapter", async () => { - setActivePluginRegistry(registryWithDemoLoaderNoOutbound); - expect(await loadChannelOutboundAdapter("demo-loader")).toBeUndefined(); + it.each([ + { + name: "loads channel plugins from the active registry", + kind: "plugin" as const, + registry: registryWithDemoLoader, + expectedPlugin: demoLoaderPlugin, + }, + { + name: "loads outbound adapters from registered plugins", + kind: "outbound" as const, + registry: registryWithDemoLoader, + expectedOutbound: demoOutbound, + }, + { + name: "refreshes cached plugin values when registry changes", + kind: "reload-plugin" as const, + firstRegistry: registryWithDemoLoader, + secondRegistry: registryWithDemoLoaderV2, + firstExpected: demoLoaderPlugin, + secondExpected: demoLoaderPluginV2, + }, + { + name: "refreshes cached outbound values when registry changes", + kind: "reload-outbound" as const, + firstRegistry: registryWithDemoLoader, + secondRegistry: registryWithDemoLoaderV2, + firstExpected: demoOutbound, + secondExpected: demoOutboundV2, + }, + { + name: "returns undefined when plugin has no outbound adapter", + kind: "missing-outbound" as const, + registry: registryWithDemoLoaderNoOutbound, + }, + ] as const)("$name", async (testCase) => { + switch (testCase.kind) { + case "plugin": + await expectLoadedPluginCase({ + registry: testCase.registry, + expectedPlugin: testCase.expectedPlugin, + }); + return; + case "outbound": + await expectLoadedOutboundCase({ + registry: testCase.registry, + expectedOutbound: testCase.expectedOutbound, + }); + return; + case "reload-plugin": + await expectReloadedLoaderCase({ + load: loadChannelPlugin, + firstRegistry: testCase.firstRegistry, + secondRegistry: testCase.secondRegistry, + firstExpected: testCase.firstExpected, + secondExpected: testCase.secondExpected, + }); + return; + case "reload-outbound": + await expectReloadedLoaderCase({ + load: loadChannelOutboundAdapter, + firstRegistry: testCase.firstRegistry, + secondRegistry: testCase.secondRegistry, + firstExpected: testCase.firstExpected, + secondExpected: testCase.secondExpected, + }); + return; + case "missing-outbound": + await expectOutboundAdapterMissingCase(testCase.registry); + return; + } }); }); describe("resolveChannelConfigWrites", () => { - it("defaults to allow when unset", () => { - const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId })).toBe(true); - }); - - it("blocks when channel config disables writes", () => { - const cfg = { channels: { [demoOriginChannelId]: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId })).toBe(false); - }); - - it("account override wins over channel default", () => { - const cfg = makeDemoConfigWritesCfg("work"); + function expectResolvedChannelConfigWrites(params: { + cfg: Record; + channelId: string; + accountId?: string; + expected: boolean; + }) { expect( - resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId, accountId: "work" }), - ).toBe(false); - }); + resolveChannelConfigWrites({ + cfg: params.cfg, + channelId: params.channelId, + ...(params.accountId ? { accountId: params.accountId } : {}), + }), + ).toBe(params.expected); + } - it("matches account ids case-insensitively", () => { - const cfg = makeDemoConfigWritesCfg("Work"); - expect( - resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId, accountId: "work" }), - ).toBe(false); + it.each([ + { + name: "defaults to allow when unset", + cfg: {}, + channelId: demoOriginChannelId, + expected: true, + }, + { + name: "blocks when channel config disables writes", + cfg: { channels: { [demoOriginChannelId]: { configWrites: false } } }, + channelId: demoOriginChannelId, + expected: false, + }, + { + name: "account override wins over channel default", + cfg: makeDemoConfigWritesCfg("work"), + channelId: demoOriginChannelId, + accountId: "work", + expected: false, + }, + { + name: "matches account ids case-insensitively", + cfg: makeDemoConfigWritesCfg("Work"), + channelId: demoOriginChannelId, + accountId: "work", + expected: false, + }, + ] as const)("$name", (testCase) => { + expectResolvedChannelConfigWrites(testCase); }); }); @@ -489,27 +642,56 @@ describe("authorizeConfigWrite", () => { }); } - it("blocks when a target account disables writes", () => { - expectConfigWriteBlocked({ + function expectAuthorizedConfigWriteCase( + input: Parameters[0], + expected: ReturnType, + ) { + expect(authorizeConfigWrite(input)).toEqual(expected); + } + + function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) { + expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected); + } + + function expectExplicitConfigWriteTargetCase( + input: Parameters[0], + expected: ReturnType, + ) { + expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected); + } + + function expectFormattedDeniedMessage( + result: Exclude, { allowed: true }>, + ) { + expect( + formatConfigWriteDeniedMessage({ + result, + }), + ).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`); + } + + it.each([ + { + name: "blocks when a target account disables writes", disabledAccountId: "work", reason: "target-disabled", blockedScope: "target", - }); - }); - - it("blocks when the origin account disables writes", () => { - expectConfigWriteBlocked({ + }, + { + name: "blocks when the origin account disables writes", disabledAccountId: "default", reason: "origin-disabled", blockedScope: "origin", - }); + }, + ] as const)("$name", (testCase) => { + expectConfigWriteBlocked(testCase); }); - it("allows bypass for internal operator.admin writes", () => { - const cfg = makeDemoConfigWritesCfg("work"); - expect( - authorizeConfigWrite({ - cfg, + it.each([ + { + name: "allows bypass for internal operator.admin writes", + input: { + cfg: makeDemoConfigWritesCfg("work"), origin: { channelId: demoOriginChannelId, accountId: "default" }, target: resolveExplicitConfigWriteTarget({ channelId: demoTargetChannelId, @@ -519,57 +701,71 @@ describe("authorizeConfigWrite", () => { channel: INTERNAL_MESSAGE_CHANNEL, gatewayClientScopes: ["operator.admin"], }), - }), - ).toEqual({ allowed: true }); - }); - - it("treats non-channel config paths as global writes", () => { - const cfg = makeDemoConfigWritesCfg("work"); - expect( - authorizeConfigWrite({ - cfg, + }, + expected: { allowed: true }, + }, + { + name: "treats non-channel config paths as global writes", + input: { + cfg: makeDemoConfigWritesCfg("work"), origin: { channelId: demoOriginChannelId, accountId: "default" }, target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]), - }), - ).toEqual({ allowed: true }); + }, + expected: { allowed: true }, + }, + ] as const)("$name", ({ input, expected }) => { + expectAuthorizedConfigWriteCase(input, expected); }); - it("rejects ambiguous channel collection writes", () => { - expect(resolveConfigWriteTargetFromPath(["channels", "demo-channel"])).toEqual({ - kind: "ambiguous", - scopes: [{ channelId: "demo-channel" }], - }); - expect(resolveConfigWriteTargetFromPath(["channels", "demo-channel", "accounts"])).toEqual({ - kind: "ambiguous", - scopes: [{ channelId: "demo-channel" }], - }); + it.each([ + { + name: "rejects bare channel collection writes", + pathSegments: ["channels", "demo-channel"], + expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] }, + }, + { + name: "rejects account collection writes", + pathSegments: ["channels", "demo-channel", "accounts"], + expected: { kind: "ambiguous", scopes: [{ channelId: "demo-channel" }] }, + }, + ] as const)("$name", ({ pathSegments, expected }) => { + expectResolvedConfigWriteTargetCase(pathSegments, expected); }); - it("resolves explicit channel and account targets", () => { - expect(resolveExplicitConfigWriteTarget({ channelId: demoOriginChannelId })).toEqual({ - kind: "channel", - scope: { channelId: demoOriginChannelId }, - }); - expect( - resolveExplicitConfigWriteTarget({ channelId: demoTargetChannelId, accountId: "work" }), - ).toEqual({ - kind: "account", - scope: { channelId: demoTargetChannelId, accountId: "work" }, - }); + it.each([ + { + name: "resolves explicit channel target", + input: { channelId: demoOriginChannelId }, + expected: { + kind: "channel", + scope: { channelId: demoOriginChannelId }, + }, + }, + { + name: "resolves explicit account target", + input: { channelId: demoTargetChannelId, accountId: "work" }, + expected: { + kind: "account", + scope: { channelId: demoTargetChannelId, accountId: "work" }, + }, + }, + ] as const)("$name", ({ input, expected }) => { + expectExplicitConfigWriteTargetCase(input, expected); }); - it("formats denied messages consistently", () => { - expect( - formatConfigWriteDeniedMessage({ - result: { - allowed: false, - reason: "target-disabled", - blockedScope: { - kind: "target", - scope: { channelId: demoTargetChannelId, accountId: "work" }, - }, + it.each([ + { + name: "formats denied messages consistently", + result: { + allowed: false, + reason: "target-disabled", + blockedScope: { + kind: "target", + scope: { channelId: demoTargetChannelId, accountId: "work" }, }, - }), - ).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`); + } as const, + }, + ] as const)("$name", ({ result }) => { + expectFormattedDeniedMessage(result); }); }); diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts index eda365c7b1d..57338a335d4 100644 --- a/src/channels/plugins/contracts/registry.contract.test.ts +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -4,9 +4,18 @@ import { sessionBindingContractChannelIds } from "./manifest.js"; const discordSessionBindingAdapterChannels = ["discord"] as const; describe("channel contract registry", () => { - it("keeps core session binding coverage aligned with built-in adapters", () => { + function expectSessionBindingCoverage(expectedChannelIds: readonly string[]) { expect([...sessionBindingContractChannelIds]).toEqual( - expect.arrayContaining([...discordSessionBindingAdapterChannels, "telegram"]), + expect.arrayContaining([...expectedChannelIds]), ); + } + + it.each([ + { + name: "keeps core session binding coverage aligned with built-in adapters", + expectedChannelIds: [...discordSessionBindingAdapterChannels, "telegram"], + }, + ] as const)("$name", ({ expectedChannelIds }) => { + expectSessionBindingCoverage(expectedChannelIds); }); }); diff --git a/src/pairing/pairing-challenge.test.ts b/src/pairing/pairing-challenge.test.ts index cb447499005..68beca4ae9f 100644 --- a/src/pairing/pairing-challenge.test.ts +++ b/src/pairing/pairing-challenge.test.ts @@ -2,89 +2,157 @@ import { describe, expect, it, vi } from "vitest"; import { issuePairingChallenge } from "./pairing-challenge.js"; describe("issuePairingChallenge", () => { - it("creates and sends a pairing reply when request is newly created", async () => { - const sent: string[] = []; - - const result = await issuePairingChallenge({ + function createBaseChallengeParams() { + return { channel: "telegram", senderId: "123", senderIdLine: "Your Telegram user id: 123", - upsertPairingRequest: async () => ({ code: "ABCD", created: true }), + } as const; + } + + async function issueChallengeAndCaptureReply( + params: Omit[0], "sendPairingReply">, + ) { + const sent: string[] = []; + const result = await issuePairingChallenge({ + ...params, sendPairingReply: async (text) => { sent.push(text); }, }); + return { result, sent }; + } - expect(result).toEqual({ created: true, code: "ABCD" }); + function expectReplyTexts(sent: string[], expectedTexts: readonly string[]) { + expect(sent).toEqual([...expectedTexts]); + } + + function expectReplyContaining(sent: string[], expectedText: string) { expect(sent).toHaveLength(1); - expect(sent[0]).toContain("ABCD"); - }); + expect(sent[0]).toContain(expectedText); + } - it("does not send a reply when request already exists", async () => { - const sendPairingReply = vi.fn(async () => {}); + async function expectIssuedChallengeCase(params: { + issueParams: Omit[0], "sendPairingReply">; + expectedResult: Awaited>; + assertReply?: (sent: string[]) => void; + sendPairingReply?: Parameters[0]["sendPairingReply"]; + assertResult?: () => void; + }) { + if (params.sendPairingReply) { + const result = await issuePairingChallenge({ + ...params.issueParams, + sendPairingReply: params.sendPairingReply, + }); + expect(result).toEqual(params.expectedResult); + params.assertResult?.(); + return; + } - const result = await issuePairingChallenge({ - channel: "telegram", - senderId: "123", - senderIdLine: "Your Telegram user id: 123", - upsertPairingRequest: async () => ({ code: "ABCD", created: false }), - sendPairingReply, - }); + const { result, sent } = await issueChallengeAndCaptureReply(params.issueParams); + expect(result).toEqual(params.expectedResult); + params.assertReply?.(sent); + params.assertResult?.(); + } - expect(result).toEqual({ created: false }); - expect(sendPairingReply).not.toHaveBeenCalled(); - }); - - it("supports custom reply text builder", async () => { - const sent: string[] = []; - - await issuePairingChallenge({ - channel: "line", - senderId: "u1", - senderIdLine: "Your line id: u1", - upsertPairingRequest: async () => ({ code: "ZXCV", created: true }), - buildReplyText: ({ code }) => `custom ${code}`, - sendPairingReply: async (text) => { - sent.push(text); + it.each([ + { + name: "creates and sends a pairing reply when request is newly created", + issueParams: { + ...createBaseChallengeParams(), + upsertPairingRequest: async () => ({ code: "ABCD", created: true }), }, - }); - - expect(sent).toEqual(["custom ZXCV"]); - }); - - it("calls onCreated and forwards meta to upsert", async () => { - const onCreated = vi.fn(); - const upsert = vi.fn(async () => ({ code: "1111", created: true })); - - await issuePairingChallenge({ - channel: "discord", - senderId: "42", - senderIdLine: "Your Discord user id: 42", - meta: { name: "alice" }, - upsertPairingRequest: upsert, - onCreated, - sendPairingReply: async () => {}, - }); - - expect(upsert).toHaveBeenCalledWith({ id: "42", meta: { name: "alice" } }); - expect(onCreated).toHaveBeenCalledWith({ code: "1111" }); - }); - - it("captures reply errors through onReplyError", async () => { - const onReplyError = vi.fn(); - - const result = await issuePairingChallenge({ - channel: "signal", - senderId: "+1555", - senderIdLine: "Your Signal sender id: +1555", - upsertPairingRequest: async () => ({ code: "9999", created: true }), - sendPairingReply: async () => { - throw new Error("send failed"); + expectedResult: { created: true, code: "ABCD" }, + assertReply: (sent: string[]) => { + expectReplyContaining(sent, "ABCD"); }, - onReplyError, + }, + { + name: "supports custom reply text builder", + issueParams: { + channel: "line", + senderId: "u1", + senderIdLine: "Your line id: u1", + upsertPairingRequest: async () => ({ code: "ZXCV", created: true }), + buildReplyText: ({ code }: { code: string }) => `custom ${code}`, + }, + expectedResult: { created: true, code: "ZXCV" }, + assertReply: (sent: string[]) => { + expectReplyTexts(sent, ["custom ZXCV"]); + }, + }, + ] as const)("$name", async ({ issueParams, expectedResult, assertReply }) => { + await expectIssuedChallengeCase({ + issueParams, + expectedResult, + assertReply, }); + }); - expect(result).toEqual({ created: true, code: "9999" }); - expect(onReplyError).toHaveBeenCalledTimes(1); + it.each([ + { + name: "does not send a reply when request already exists", + setup: () => { + const sendPairingReply = vi.fn(async () => {}); + return { + issueParams: { + ...createBaseChallengeParams(), + upsertPairingRequest: async () => ({ code: "ABCD", created: false }), + }, + sendPairingReply, + expectedResult: { created: false }, + assertResult: () => { + expect(sendPairingReply).not.toHaveBeenCalled(); + }, + }; + }, + }, + { + name: "calls onCreated and forwards meta to upsert", + setup: () => { + const onCreated = vi.fn(); + const upsert = vi.fn(async () => ({ code: "1111", created: true })); + return { + issueParams: { + channel: "discord", + senderId: "42", + senderIdLine: "Your Discord user id: 42", + meta: { name: "alice" }, + upsertPairingRequest: upsert, + onCreated, + }, + sendPairingReply: async () => {}, + expectedResult: { created: true, code: "1111" }, + assertResult: () => { + expect(upsert).toHaveBeenCalledWith({ id: "42", meta: { name: "alice" } }); + expect(onCreated).toHaveBeenCalledWith({ code: "1111" }); + }, + }; + }, + }, + { + name: "captures reply errors through onReplyError", + setup: () => { + const onReplyError = vi.fn(); + return { + issueParams: { + channel: "signal", + senderId: "+1555", + senderIdLine: "Your Signal sender id: +1555", + upsertPairingRequest: async () => ({ code: "9999", created: true }), + onReplyError, + }, + sendPairingReply: async () => { + throw new Error("send failed"); + }, + expectedResult: { created: true, code: "9999" }, + assertResult: () => { + expect(onReplyError).toHaveBeenCalledTimes(1); + }, + }; + }, + }, + ] as const)("$name", async ({ setup }) => { + await expectIssuedChallengeCase(setup()); }); }); diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index 94b9da62b4b..4c75602c9ff 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -16,7 +16,7 @@ describe("buildPairingReply", () => { envSnapshot.restore(); }); - const cases = [ + const pairingReplyCases = [ { channel: "telegram", idLine: "Your Telegram user id: 42", @@ -49,13 +49,20 @@ describe("buildPairingReply", () => { }, ] as const; - it.each(cases)("formats pairing reply for $channel", (testCase) => { - const text = buildPairingReply(testCase); - expectPairingReplyText(text, testCase); - // CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile) + function expectPairingApproveCommand(text: string, testCase: (typeof pairingReplyCases)[number]) { const commandRe = new RegExp( `(?:openclaw|openclaw) --profile isolated pairing approve ${testCase.channel} ${testCase.code}`, ); expect(text).toMatch(commandRe); + } + + function expectProfileAwarePairingReply(testCase: (typeof pairingReplyCases)[number]) { + const text = buildPairingReply(testCase); + expectPairingReplyText(text, testCase); + expectPairingApproveCommand(text, testCase); + } + + it.each(pairingReplyCases)("formats pairing reply for $channel", (testCase) => { + expectProfileAwarePairingReply(testCase); }); }); diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index af9a973ac33..ea0da7ba9d4 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -132,12 +132,6 @@ async function expectAccountScopedEntryIsolated(entry: string, accountId = "yy") expect(channelScoped).not.toContain(entry); } -async function readScopedAllowFromPair(accountId: string) { - const asyncScoped = await readChannelAllowFromStore("telegram", process.env, accountId); - const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, accountId); - return { asyncScoped, syncScoped }; -} - async function withAllowFromCacheReadSpy(params: { stateDir: string; createReadSpy: () => { @@ -168,310 +162,419 @@ async function seedDefaultAccountAllowFromFixture(stateDir: string) { }); } -describe("pairing store", () => { - it("reuses pending code and reports created=false", async () => { - await withTempStateDir(async () => { - const first = await upsertChannelPairingRequest({ - channel: "demo-pairing-a", - id: "u1", - accountId: DEFAULT_ACCOUNT_ID, - }); - const second = await upsertChannelPairingRequest({ - channel: "demo-pairing-a", - id: "u1", - accountId: DEFAULT_ACCOUNT_ID, - }); - expect(first.created).toBe(true); - expect(second.created).toBe(false); - expect(second.code).toBe(first.code); +async function expectPairingRequestStateCase(params: { run: () => Promise }) { + await params.run(); +} - const list = await listChannelPairingRequests("demo-pairing-a"); - expect(list).toHaveLength(1); - expect(list[0]?.code).toBe(first.code); - }); +async function withMockRandomInt(params: { + initialValue?: number; + sequence?: number[]; + fallbackValue?: number; + run: () => Promise; +}) { + const spy = vi.spyOn(crypto, "randomInt") as unknown as { + mockReturnValue: (value: number) => void; + mockImplementation: (fn: () => number) => void; + mockRestore: () => void; + }; + + try { + if (params.initialValue !== undefined) { + spy.mockReturnValue(params.initialValue); + } + + if (params.sequence) { + let idx = 0; + spy.mockImplementation(() => params.sequence?.[idx++] ?? params.fallbackValue ?? 1); + } + + await params.run(); + } finally { + spy.mockRestore(); + } +} + +async function expectAllowFromReadConsistencyCase(params: { + accountId?: string; + expected: readonly string[]; +}) { + const asyncScoped = await readChannelAllowFromStore("telegram", process.env, params.accountId); + const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, params.accountId); + expect(asyncScoped).toEqual(params.expected); + expect(syncScoped).toEqual(params.expected); +} + +async function expectPendingPairingRequestsIsolatedByAccount(params: { + sharedId: string; + firstAccountId: string; + secondAccountId: string; +}) { + const first = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: params.firstAccountId, + id: params.sharedId, + }); + const second = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: params.secondAccountId, + id: params.sharedId, }); - it("expires pending requests after TTL", async () => { - await withTempStateDir(async (stateDir) => { - const created = await upsertChannelPairingRequest({ - channel: "demo-pairing-b", - id: "+15550001111", - accountId: DEFAULT_ACCOUNT_ID, - }); - expect(created.created).toBe(true); + expect(first.created).toBe(true); + expect(second.created).toBe(true); + expect(second.code).not.toBe(first.code); - const filePath = resolvePairingFilePath(stateDir, "demo-pairing-b"); - const raw = await fs.readFile(filePath, "utf8"); - const parsed = JSON.parse(raw) as { - requests?: Array>; - }; - const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - const requests = (parsed.requests ?? []).map((entry) => ({ - ...entry, - createdAt: expiredAt, - lastSeenAt: expiredAt, - })); - await writeJsonFixture(filePath, { version: 1, requests }); + const firstList = await listChannelPairingRequests( + "telegram", + process.env, + params.firstAccountId, + ); + const secondList = await listChannelPairingRequests( + "telegram", + process.env, + params.secondAccountId, + ); + expect(firstList).toHaveLength(1); + expect(secondList).toHaveLength(1); + expect(firstList[0]?.code).toBe(first.code); + expect(secondList[0]?.code).toBe(second.code); +} - const list = await listChannelPairingRequests("demo-pairing-b"); - expect(list).toHaveLength(0); +async function expectScopedAllowFromReadCase(params: { + stateDir: string; + legacyAllowFrom: string[]; + scopedAllowFrom: string[]; + accountId: string; + expectedScoped: string[]; + expectedLegacy: string[]; +}) { + await writeAllowFromFixture({ + stateDir: params.stateDir, + channel: "telegram", + allowFrom: params.legacyAllowFrom, + }); + await writeAllowFromFixture({ + stateDir: params.stateDir, + channel: "telegram", + accountId: params.accountId, + allowFrom: params.scopedAllowFrom, + }); - const next = await upsertChannelPairingRequest({ - channel: "demo-pairing-b", - id: "+15550001111", - accountId: DEFAULT_ACCOUNT_ID, - }); - expect(next.created).toBe(true); - }); + const scoped = readChannelAllowFromStoreSync("telegram", process.env, params.accountId); + const channelScoped = readLegacyChannelAllowFromStoreSync("telegram"); + expect(scoped).toEqual(params.expectedScoped); + expect(channelScoped).toEqual(params.expectedLegacy); +} + +describe("pairing store", () => { + it.each([ + { + name: "reuses pending code and reports created=false", + run: async () => { + await withTempStateDir(async () => { + const first = await upsertChannelPairingRequest({ + channel: "demo-pairing-a", + id: "u1", + accountId: DEFAULT_ACCOUNT_ID, + }); + const second = await upsertChannelPairingRequest({ + channel: "demo-pairing-a", + id: "u1", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.code).toBe(first.code); + + const list = await listChannelPairingRequests("demo-pairing-a"); + expect(list).toHaveLength(1); + expect(list[0]?.code).toBe(first.code); + }); + }, + }, + { + name: "expires pending requests after TTL", + run: async () => { + await withTempStateDir(async (stateDir) => { + const created = await upsertChannelPairingRequest({ + channel: "demo-pairing-b", + id: "+15550001111", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(created.created).toBe(true); + + const filePath = resolvePairingFilePath(stateDir, "demo-pairing-b"); + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { + requests?: Array>; + }; + const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const requests = (parsed.requests ?? []).map((entry) => ({ + ...entry, + createdAt: expiredAt, + lastSeenAt: expiredAt, + })); + await writeJsonFixture(filePath, { version: 1, requests }); + + const list = await listChannelPairingRequests("demo-pairing-b"); + expect(list).toHaveLength(0); + + const next = await upsertChannelPairingRequest({ + channel: "demo-pairing-b", + id: "+15550001111", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(next.created).toBe(true); + }); + }, + }, + { + name: "caps pending requests at the default limit", + run: async () => { + await withTempStateDir(async () => { + const ids = ["+15550000001", "+15550000002", "+15550000003"]; + for (const id of ids) { + const created = await upsertChannelPairingRequest({ + channel: "demo-pairing-c", + id, + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(created.created).toBe(true); + } + + const blocked = await upsertChannelPairingRequest({ + channel: "demo-pairing-c", + id: "+15550000004", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(blocked.created).toBe(false); + + const list = await listChannelPairingRequests("demo-pairing-c"); + const listIds = list.map((entry) => entry.id); + expect(listIds).toHaveLength(3); + expect(listIds).toContain("+15550000001"); + expect(listIds).toContain("+15550000002"); + expect(listIds).toContain("+15550000003"); + expect(listIds).not.toContain("+15550000004"); + }); + }, + }, + ] as const)("$name", async ({ run }) => { + await expectPairingRequestStateCase({ run }); }); it("regenerates when a generated code collides", async () => { await withTempStateDir(async () => { - const spy = vi.spyOn(crypto, "randomInt") as unknown as { - mockReturnValue: (value: number) => void; - mockImplementation: (fn: () => number) => void; - mockRestore: () => void; - }; - try { - spy.mockReturnValue(0); - const first = await upsertChannelPairingRequest({ - channel: "telegram", - id: "123", - accountId: DEFAULT_ACCOUNT_ID, + await withMockRandomInt({ + initialValue: 0, + run: async () => { + const first = await upsertChannelPairingRequest({ + channel: "telegram", + id: "123", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(first.code).toBe("AAAAAAAA"); + + await withMockRandomInt({ + sequence: Array(8).fill(0).concat(Array(8).fill(1)), + fallbackValue: 1, + run: async () => { + const second = await upsertChannelPairingRequest({ + channel: "telegram", + id: "456", + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(second.code).toBe("BBBBBBBB"); + }, + }); + }, + }); + }); + }); + + it.each([ + { + name: "stores allowFrom entries per account when accountId is provided", + run: async () => { + await withTempStateDir(async () => { + await addChannelAllowFromStoreEntry({ + channel: "telegram", + accountId: "yy", + entry: "12345", + }); + + await expectAccountScopedEntryIsolated("12345"); }); - expect(first.code).toBe("AAAAAAAA"); + }, + }, + { + name: "approves pairing codes into account-scoped allowFrom via pairing metadata", + run: async () => { + await withTempStateDir(async () => { + const created = await createTelegramPairingRequest("yy"); - const sequence = Array(8).fill(0).concat(Array(8).fill(1)); - let idx = 0; - spy.mockImplementation(() => sequence[idx++] ?? 1); - const second = await upsertChannelPairingRequest({ - channel: "telegram", - id: "456", - accountId: DEFAULT_ACCOUNT_ID, + const approved = await approveChannelPairingCode({ + channel: "telegram", + code: created.code, + }); + expect(approved?.id).toBe("12345"); + + await expectAccountScopedEntryIsolated("12345"); }); - expect(second.code).toBe("BBBBBBBB"); - } finally { - spy.mockRestore(); - } - }); - }); + }, + }, + { + name: "filters approvals by account id and ignores blank approval codes", + run: async () => { + await withTempStateDir(async () => { + const created = await createTelegramPairingRequest("yy"); - it("caps pending requests at the default limit", async () => { - await withTempStateDir(async () => { - const ids = ["+15550000001", "+15550000002", "+15550000003"]; - for (const id of ids) { - const created = await upsertChannelPairingRequest({ - channel: "demo-pairing-c", - id, - accountId: DEFAULT_ACCOUNT_ID, + const blank = await approveChannelPairingCode({ + channel: "telegram", + code: " ", + }); + expect(blank).toBeNull(); + + const mismatched = await approveChannelPairingCode({ + channel: "telegram", + code: created.code, + accountId: "zz", + }); + expect(mismatched).toBeNull(); + + const pending = await listChannelPairingRequests("telegram"); + expect(pending).toHaveLength(1); + expect(pending[0]?.id).toBe("12345"); }); - expect(created.created).toBe(true); - } + }, + }, + { + name: "removes account-scoped allowFrom entries idempotently", + run: async () => { + await withTempStateDir(async () => { + await addChannelAllowFromStoreEntry({ + channel: "telegram", + accountId: "yy", + entry: "12345", + }); - const blocked = await upsertChannelPairingRequest({ - channel: "demo-pairing-c", - id: "+15550000004", - accountId: DEFAULT_ACCOUNT_ID, - }); - expect(blocked.created).toBe(false); + const removed = await removeChannelAllowFromStoreEntry({ + channel: "telegram", + accountId: "yy", + entry: "12345", + }); + expect(removed.changed).toBe(true); + expect(removed.allowFrom).toEqual([]); - const list = await listChannelPairingRequests("demo-pairing-c"); - const listIds = list.map((entry) => entry.id); - expect(listIds).toHaveLength(3); - expect(listIds).toContain("+15550000001"); - expect(listIds).toContain("+15550000002"); - expect(listIds).toContain("+15550000003"); - expect(listIds).not.toContain("+15550000004"); - }); - }); - - it("stores allowFrom entries per account when accountId is provided", async () => { - await withTempStateDir(async () => { - await addChannelAllowFromStoreEntry({ - channel: "telegram", - accountId: "yy", - entry: "12345", - }); - - await expectAccountScopedEntryIsolated("12345"); - }); - }); - - it("approves pairing codes into account-scoped allowFrom via pairing metadata", async () => { - await withTempStateDir(async () => { - const created = await createTelegramPairingRequest("yy"); - - const approved = await approveChannelPairingCode({ - channel: "telegram", - code: created.code, - }); - expect(approved?.id).toBe("12345"); - - await expectAccountScopedEntryIsolated("12345"); - }); - }); - - it("filters approvals by account id and ignores blank approval codes", async () => { - await withTempStateDir(async () => { - const created = await createTelegramPairingRequest("yy"); - - const blank = await approveChannelPairingCode({ - channel: "telegram", - code: " ", - }); - expect(blank).toBeNull(); - - const mismatched = await approveChannelPairingCode({ - channel: "telegram", - code: created.code, - accountId: "zz", - }); - expect(mismatched).toBeNull(); - - const pending = await listChannelPairingRequests("telegram"); - expect(pending).toHaveLength(1); - expect(pending[0]?.id).toBe("12345"); - }); - }); - - it("removes account-scoped allowFrom entries idempotently", async () => { - await withTempStateDir(async () => { - await addChannelAllowFromStoreEntry({ - channel: "telegram", - accountId: "yy", - entry: "12345", - }); - - const removed = await removeChannelAllowFromStoreEntry({ - channel: "telegram", - accountId: "yy", - entry: "12345", - }); - expect(removed.changed).toBe(true); - expect(removed.allowFrom).toEqual([]); - - const removedAgain = await removeChannelAllowFromStoreEntry({ - channel: "telegram", - accountId: "yy", - entry: "12345", - }); - expect(removedAgain.changed).toBe(false); - expect(removedAgain.allowFrom).toEqual([]); - }); + const removedAgain = await removeChannelAllowFromStoreEntry({ + channel: "telegram", + accountId: "yy", + entry: "12345", + }); + expect(removedAgain.changed).toBe(false); + expect(removedAgain.allowFrom).toEqual([]); + }); + }, + }, + ] as const)("$name", async ({ run }) => { + await expectPairingRequestStateCase({ run }); }); it("reads sync allowFrom with account-scoped isolation and wildcard filtering", async () => { await withTempStateDir(async (stateDir) => { - await writeAllowFromFixture({ + await expectScopedAllowFromReadCase({ stateDir, - channel: "telegram", - allowFrom: ["1001", "*", " 1001 ", " "], - }); - await writeAllowFromFixture({ - stateDir, - channel: "telegram", + legacyAllowFrom: ["1001", "*", " 1001 ", " "], + scopedAllowFrom: [" 1002 ", "1001", "1002"], accountId: "yy", - allowFrom: [" 1002 ", "1001", "1002"], + expectedScoped: ["1002", "1001"], + expectedLegacy: ["1001"], }); - - const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); - const channelScoped = readLegacyChannelAllowFromStoreSync("telegram"); - expect(scoped).toEqual(["1002", "1001"]); - expect(channelScoped).toEqual(["1001"]); }); }); - it("does not read legacy channel-scoped allowFrom for non-default account ids", async () => { + it.each([ + { + name: "does not read legacy channel-scoped allowFrom for non-default account ids", + setup: async (stateDir: string) => { + await seedTelegramAllowFromFixtures({ + stateDir, + scopedAccountId: "yy", + scopedAllowFrom: ["1003"], + legacyAllowFrom: ["1001", "*", "1002", "1001"], + }); + }, + accountId: "yy", + expected: ["1003"], + }, + { + name: "does not fall back to legacy allowFrom when scoped file exists but is empty", + setup: async (stateDir: string) => { + await seedTelegramAllowFromFixtures({ + stateDir, + scopedAccountId: "yy", + scopedAllowFrom: [], + }); + }, + accountId: "yy", + expected: [], + }, + { + name: "keeps async and sync reads aligned for malformed scoped allowFrom files", + setup: async (stateDir: string) => { + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + allowFrom: ["1001"], + }); + const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy"); + await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true }); + await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8"); + }, + accountId: "yy", + expected: [], + }, + { + name: "reads legacy channel-scoped allowFrom for default account", + setup: async (stateDir: string) => { + await seedDefaultAccountAllowFromFixture(stateDir); + }, + accountId: DEFAULT_ACCOUNT_ID, + expected: ["1002", "1001"], + }, + { + name: "uses default-account allowFrom when account id is omitted", + setup: async (stateDir: string) => { + await seedDefaultAccountAllowFromFixture(stateDir); + }, + accountId: undefined, + expected: ["1002", "1001"], + }, + ] as const)("$name", async ({ setup, accountId, expected }) => { await withTempStateDir(async (stateDir) => { - await seedTelegramAllowFromFixtures({ - stateDir, - scopedAccountId: "yy", - scopedAllowFrom: ["1003"], - legacyAllowFrom: ["1001", "*", "1002", "1001"], + await setup(stateDir); + await expectAllowFromReadConsistencyCase({ + ...(accountId !== undefined ? { accountId } : {}), + expected, }); - - const { asyncScoped, syncScoped } = await readScopedAllowFromPair("yy"); - expect(asyncScoped).toEqual(["1003"]); - expect(syncScoped).toEqual(["1003"]); }); }); - it("does not fall back to legacy allowFrom when scoped file exists but is empty", async () => { - await withTempStateDir(async (stateDir) => { - await seedTelegramAllowFromFixtures({ - stateDir, - scopedAccountId: "yy", - scopedAllowFrom: [], - }); - - const { asyncScoped, syncScoped } = await readScopedAllowFromPair("yy"); - expect(asyncScoped).toEqual([]); - expect(syncScoped).toEqual([]); - }); - }); - - it("keeps async and sync reads aligned for malformed scoped allowFrom files", async () => { - await withTempStateDir(async (stateDir) => { - await writeAllowFromFixture({ - stateDir, - channel: "telegram", - allowFrom: ["1001"], - }); - const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy"); - await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true }); - await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8"); - - const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); - const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); - expect(asyncScoped).toEqual([]); - expect(syncScoped).toEqual([]); - }); - }); - - it("does not reuse pairing requests across accounts for the same sender id", async () => { - await withTempStateDir(async () => { - const first = await upsertChannelPairingRequest({ - channel: "telegram", - accountId: "alpha", - id: "12345", - }); - const second = await upsertChannelPairingRequest({ - channel: "telegram", - accountId: "beta", - id: "12345", - }); - - expect(first.created).toBe(true); - expect(second.created).toBe(true); - expect(second.code).not.toBe(first.code); - - const alpha = await listChannelPairingRequests("telegram", process.env, "alpha"); - const beta = await listChannelPairingRequests("telegram", process.env, "beta"); - expect(alpha).toHaveLength(1); - expect(beta).toHaveLength(1); - expect(alpha[0]?.code).toBe(first.code); - expect(beta[0]?.code).toBe(second.code); - }); - }); - - it("reads legacy channel-scoped allowFrom for default account", async () => { - await withTempStateDir(async (stateDir) => { - await seedDefaultAccountAllowFromFixture(stateDir); - - const scoped = await readChannelAllowFromStore("telegram", process.env, DEFAULT_ACCOUNT_ID); - expect(scoped).toEqual(["1002", "1001"]); - }); - }); - - it("uses default-account allowFrom when account id is omitted", async () => { - await withTempStateDir(async (stateDir) => { - await seedDefaultAccountAllowFromFixture(stateDir); - - const asyncScoped = await readChannelAllowFromStore("telegram", process.env); - const syncScoped = readChannelAllowFromStoreSync("telegram", process.env); - expect(asyncScoped).toEqual(["1002", "1001"]); - expect(syncScoped).toEqual(["1002", "1001"]); - }); + it.each([ + { + name: "does not reuse pairing requests across accounts for the same sender id", + run: async () => { + await withTempStateDir(async () => { + await expectPendingPairingRequestsIsolatedByAccount({ + sharedId: "12345", + firstAccountId: "alpha", + secondAccountId: "beta", + }); + }); + }, + }, + ] as const)("$name", async ({ run }) => { + await expectPairingRequestStateCase({ run }); }); it.each([ diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index bebbf63bee4..96d5a0429c4 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -14,6 +14,9 @@ let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js") describe("pairing setup code", () => { type ResolvedSetup = Awaited>; + type ResolveSetupConfig = Parameters[0]; + type ResolveSetupOptions = Parameters[1]; + type ResolveSetupEnv = NonNullable["env"]; const defaultEnvSecretProviderConfig = { secrets: { providers: { @@ -32,6 +35,20 @@ describe("pairing setup code", () => { id: "MISSING_GW_TOKEN", }; + function createCustomGatewayConfig( + auth: NonNullable["auth"], + config: Omit = {}, + ): ResolveSetupConfig { + return { + ...config, + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth, + }, + }; + } + function createTailnetDnsRunner() { return vi.fn(async () => ({ code: 0, @@ -78,6 +95,64 @@ describe("pairing setup code", () => { expect(resolved.error).toContain(snippet); } + async function expectResolvedSetupSuccessCase(params: { + config: ResolveSetupConfig; + options?: ResolveSetupOptions; + expected: { + authLabel: string; + url: string; + urlSource: string; + }; + runCommandWithTimeout?: ReturnType; + expectedRunCommandCalls?: number; + }) { + const resolved = await resolvePairingSetupFromConfig(params.config, params.options); + expectResolvedSetupOk(resolved, params.expected); + if (params.runCommandWithTimeout) { + expect(params.runCommandWithTimeout).toHaveBeenCalledTimes( + params.expectedRunCommandCalls ?? 0, + ); + } + } + + async function expectResolvedSetupFailureCase(params: { + config: ResolveSetupConfig; + options?: ResolveSetupOptions; + expectedError: string; + }) { + const resolved = await resolvePairingSetupFromConfig(params.config, params.options); + expectResolvedSetupError(resolved, params.expectedError); + } + + async function expectResolveCustomGatewayRejects(params: { + auth: NonNullable["auth"]; + env?: ResolveSetupEnv; + config?: Omit; + expectedError: RegExp | string; + }) { + await expect( + resolveCustomGatewaySetup({ + auth: params.auth, + env: params.env, + config: params.config, + }), + ).rejects.toThrow(params.expectedError); + } + + async function expectResolvedCustomGatewaySetupOk(params: { + auth: NonNullable["auth"]; + env?: ResolveSetupEnv; + config?: Omit; + expectedAuthLabel: string; + }) { + const resolved = await resolveCustomGatewaySetup({ + auth: params.auth, + env: params.env, + config: params.config, + }); + expectResolvedSetupOk(resolved, { authLabel: params.expectedAuthLabel }); + } + beforeEach(() => { vi.resetModules(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); @@ -96,148 +171,101 @@ describe("pairing setup code", () => { vi.unstubAllEnvs(); }); - it("encodes payload as base64url JSON", () => { - const code = encodePairingSetupCode({ - url: "wss://gateway.example.com:443", - bootstrapToken: "abc", - }); - - expect(code).toBe( - "eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsImJvb3RzdHJhcFRva2VuIjoiYWJjIn0", - ); - }); - - it("resolves custom bind + token auth", async () => { - const resolved = await resolvePairingSetupFromConfig({ - gateway: { - bind: "custom", - customBindHost: "gateway.local", - port: 19001, - auth: { mode: "token", token: "tok_123" }, - }, - }); - - expect(resolved).toEqual({ - ok: true, + it.each([ + { + name: "encodes payload as base64url JSON", payload: { - url: "ws://gateway.local:19001", - bootstrapToken: "bootstrap-123", + url: "wss://gateway.example.com:443", + bootstrapToken: "abc", }, - authLabel: "token", - urlSource: "gateway.bind=custom", + expected: + "eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsImJvb3RzdHJhcFRva2VuIjoiYWJjIn0", + }, + ] as const)("$name", ({ payload, expected }) => { + expect(encodePairingSetupCode(payload)).toBe(expected); + }); + + async function resolveCustomGatewaySetup(params: { + auth: NonNullable["auth"]; + env?: ResolveSetupEnv; + config?: Omit; + }) { + return await resolvePairingSetupFromConfig( + createCustomGatewayConfig(params.auth, params.config), + { + env: params.env ?? {}, + }, + ); + } + + it.each([ + { + name: "resolves gateway.auth.password SecretRef for pairing payload", + auth: { + mode: "password", + password: gatewayPasswordSecretRef, + } as const, + env: { + GW_PASSWORD: "resolved-password", // pragma: allowlist secret + }, + expectedAuthLabel: "password", + }, + { + name: "uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", + auth: { + mode: "password", + password: { source: "env", provider: "default", id: "MISSING_GW_PASSWORD" }, + } as const, + env: { + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret + }, + expectedAuthLabel: "password", + }, + { + name: "does not resolve gateway.auth.password SecretRef in token mode", + auth: { + mode: "token", + token: "tok_123", + password: { source: "env", provider: "missing", id: "GW_PASSWORD" }, + } as const, + env: {}, + expectedAuthLabel: "token", + }, + { + name: "resolves gateway.auth.token SecretRef for pairing payload", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + } as const, + env: { + GW_TOKEN: "resolved-token", + }, + expectedAuthLabel: "token", + }, + ] as const)("$name", async ({ auth, env, expectedAuthLabel }) => { + await expectResolvedCustomGatewaySetupOk({ + auth, + env, + config: defaultEnvSecretProviderConfig, + expectedAuthLabel, }); }); - it("resolves gateway.auth.password SecretRef for pairing payload", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: gatewayPasswordSecretRef, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: { - GW_PASSWORD: "resolved-password", // pragma: allowlist secret - }, - }, - ); - - expectResolvedSetupOk(resolved, { authLabel: "password" }); - }); - - it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_GW_PASSWORD" }, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret - }, - }, - ); - - expectResolvedSetupOk(resolved, { authLabel: "password" }); - }); - - it("does not resolve gateway.auth.password SecretRef in token mode", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "token", - token: "tok_123", - password: { source: "env", provider: "missing", id: "GW_PASSWORD" }, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: {}, - }, - ); - - expectResolvedSetupOk(resolved, { authLabel: "token" }); - }); - - it("resolves gateway.auth.token SecretRef for pairing payload", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "GW_TOKEN" }, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: { - GW_TOKEN: "resolved-token", - }, - }, - ); - - expectResolvedSetupOk(resolved, { authLabel: "token" }); - }); - - it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { - await expect( - resolvePairingSetupFromConfig( + it.each([ + { + name: "errors when gateway.auth.token SecretRef is unresolved in token mode", + config: createCustomGatewayConfig( { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - mode: "token", - token: missingGatewayTokenSecretRef, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: {}, + mode: "token", + token: missingGatewayTokenSecretRef, }, + defaultEnvSecretProviderConfig, ), - ).rejects.toThrow(/MISSING_GW_TOKEN/i); + options: { env: {} }, + expectedError: "MISSING_GW_TOKEN", + }, + ] as const)("$name", async ({ config, options, expectedError }) => { + await expectResolvedSetupFailureCase({ config, options, expectedError }); }); async function resolveInferredModeWithPasswordEnv(token: SecretInput) { @@ -258,172 +286,197 @@ describe("pairing setup code", () => { ); } - it("uses password env in inferred mode without resolving token SecretRef", async () => { - const resolved = await resolveInferredModeWithPasswordEnv({ - source: "env", - provider: "default", - id: "MISSING_GW_TOKEN", + async function expectInferredPasswordEnvSetupCase(token: SecretInput) { + const resolved = await resolveInferredModeWithPasswordEnv(token); + expectResolvedSetupOk(resolved, { authLabel: "password" }); + } + + it.each([ + { + name: "uses password env in inferred mode without resolving token SecretRef", + token: { + source: "env", + provider: "default", + id: "MISSING_GW_TOKEN", + } satisfies SecretInput, + }, + { + name: "does not treat env-template token as plaintext in inferred mode", + token: "${MISSING_GW_TOKEN}", + }, + ] as const)("$name", async ({ token }) => { + await expectInferredPasswordEnvSetupCase(token); + }); + + it.each([ + { + name: "requires explicit auth mode when token and password are both configured", + auth: { + token: { source: "env", provider: "default", id: "GW_TOKEN" }, + password: gatewayPasswordSecretRef, + } as const, + env: { + GW_TOKEN: "resolved-token", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret + }, + }, + { + name: "errors when token and password SecretRefs are both configured with inferred mode", + auth: { + token: missingGatewayTokenSecretRef, + password: gatewayPasswordSecretRef, + } as const, + env: { + GW_PASSWORD: "resolved-password", // pragma: allowlist secret + }, + }, + ] as const)("$name", async ({ auth, env }) => { + await expectResolveCustomGatewayRejects({ + auth, + env, + config: defaultEnvSecretProviderConfig, + expectedError: /gateway\.auth\.mode is unset/i, }); - - expectResolvedSetupOk(resolved, { authLabel: "password" }); }); - it("does not treat env-template token as plaintext in inferred mode", async () => { - const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); - - expectResolvedSetupOk(resolved, { authLabel: "password" }); - }); - - it("requires explicit auth mode when token and password are both configured", async () => { - await expect( - resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - token: { source: "env", provider: "default", id: "GW_TOKEN" }, - password: gatewayPasswordSecretRef, - }, - }, - ...defaultEnvSecretProviderConfig, + it.each([ + { + name: "resolves custom bind + token auth", + config: { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + port: 19001, + auth: { mode: "token", token: "tok_123" }, }, - { - env: { - GW_TOKEN: "resolved-token", - GW_PASSWORD: "resolved-password", // pragma: allowlist secret - }, - }, - ), - ).rejects.toThrow(/gateway\.auth\.mode is unset/i); - }); - - it("errors when token and password SecretRefs are both configured with inferred mode", async () => { - await expect( - resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - token: missingGatewayTokenSecretRef, - password: gatewayPasswordSecretRef, - }, - }, - ...defaultEnvSecretProviderConfig, - }, - { - env: { - GW_PASSWORD: "resolved-password", // pragma: allowlist secret - }, - }, - ), - ).rejects.toThrow(/gateway\.auth\.mode is unset/i); - }); - - it("honors env token override", async () => { - const resolved = await resolvePairingSetupFromConfig( - { + } satisfies ResolveSetupConfig, + expected: { + authLabel: "token", + url: "ws://gateway.local:19001", + urlSource: "gateway.bind=custom", + }, + }, + { + name: "honors env token override", + config: { gateway: { bind: "custom", customBindHost: "gateway.local", auth: { mode: "token", token: "old" }, }, - }, - { + } satisfies ResolveSetupConfig, + options: { env: { OPENCLAW_GATEWAY_TOKEN: "new-token", }, + } satisfies ResolveSetupOptions, + expected: { + authLabel: "token", + url: "ws://gateway.local:3000", + urlSource: "gateway.bind=custom", }, - ); - - expectResolvedSetupOk(resolved, { authLabel: "token" }); - }); - - it("errors when gateway is loopback only", async () => { - const resolved = await resolvePairingSetupFromConfig({ - gateway: { - bind: "loopback", - auth: { mode: "token", token: "tok" }, - }, + }, + ] as const)("$name", async ({ config, options, expected }) => { + await expectResolvedSetupSuccessCase({ + config, + options, + expected, }); - - expectResolvedSetupError(resolved, "only bound to loopback"); }); - it("uses tailscale serve DNS when available", async () => { - const runCommandWithTimeout = createTailnetDnsRunner(); - - const resolved = await resolvePairingSetupFromConfig( - { + it.each([ + { + name: "errors when gateway is loopback only", + config: { gateway: { - tailscale: { mode: "serve" }, - auth: { mode: "password", password: "secret" }, + bind: "loopback", + auth: { mode: "token", token: "tok" }, }, - }, - { - runCommandWithTimeout, - }, - ); - - expect(resolved).toEqual({ - ok: true, - payload: { - url: "wss://mb-server.tailnet.ts.net", - bootstrapToken: "bootstrap-123", - }, - authLabel: "password", - urlSource: "gateway.tailscale.mode=serve", - }); - }); - - it("returns a bind-specific error when interface discovery throws", async () => { - const resolved = await resolvePairingSetupFromConfig( - { + } satisfies ResolveSetupConfig, + expectedError: "only bound to loopback", + }, + { + name: "returns a bind-specific error when interface discovery throws", + config: { gateway: { bind: "lan", auth: { mode: "token", token: "tok" }, }, - }, - { + } satisfies ResolveSetupConfig, + options: { networkInterfaces: () => { throw new Error("uv_interface_addresses failed"); }, - }, - ); - - expect(resolved).toEqual({ - ok: false, - error: "gateway.bind=lan set, but no private LAN IP was found.", + } satisfies ResolveSetupOptions, + expectedError: "gateway.bind=lan set, but no private LAN IP was found.", + }, + ] as const)("$name", async ({ config, options, expectedError }) => { + await expectResolvedSetupFailureCase({ + config, + options, + expectedError, }); }); - it("prefers gateway.remote.url over tailscale when requested", async () => { - const runCommandWithTimeout = createTailnetDnsRunner(); - - const resolved = await resolvePairingSetupFromConfig( - { + it.each([ + { + name: "uses tailscale serve DNS when available", + createOptions: () => { + const runCommandWithTimeout = createTailnetDnsRunner(); + return { + options: { + runCommandWithTimeout, + } satisfies ResolveSetupOptions, + runCommandWithTimeout, + expectedRunCommandCalls: 1, + }; + }, + config: { + gateway: { + tailscale: { mode: "serve" }, + auth: { mode: "password", password: "secret" }, + }, + } satisfies ResolveSetupConfig, + expected: { + authLabel: "password", + url: "wss://mb-server.tailnet.ts.net", + urlSource: "gateway.tailscale.mode=serve", + }, + }, + { + name: "prefers gateway.remote.url over tailscale when requested", + createOptions: () => { + const runCommandWithTimeout = createTailnetDnsRunner(); + return { + options: { + preferRemoteUrl: true, + runCommandWithTimeout, + } satisfies ResolveSetupOptions, + runCommandWithTimeout, + expectedRunCommandCalls: 0, + }; + }, + config: { gateway: { tailscale: { mode: "serve" }, remote: { url: "wss://remote.example.com:444" }, auth: { mode: "token", token: "tok_123" }, }, - }, - { - preferRemoteUrl: true, - runCommandWithTimeout, - }, - ); - - expect(resolved).toEqual({ - ok: true, - payload: { + } satisfies ResolveSetupConfig, + expected: { + authLabel: "token", url: "wss://remote.example.com:444", - bootstrapToken: "bootstrap-123", + urlSource: "gateway.remote.url", }, - authLabel: "token", - urlSource: "gateway.remote.url", + }, + ] as const)("$name", async ({ config, createOptions, expected }) => { + const { options, runCommandWithTimeout, expectedRunCommandCalls } = createOptions(); + await expectResolvedSetupSuccessCase({ + config, + options, + expected, + runCommandWithTimeout, + expectedRunCommandCalls, }); - expect(runCommandWithTimeout).not.toHaveBeenCalled(); }); });