test: dedupe pairing and channel contract suites

This commit is contained in:
Peter Steinberger 2026-03-28 07:31:31 +00:00
parent e7c1fcba0c
commit f36354e401
8 changed files with 1470 additions and 976 deletions

View File

@ -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,
});
},
);
});

View File

@ -11,6 +11,45 @@ import {
import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js";
describe("channel runtime group policy contract", () => {
type ResolvedGroupPolicy = ReturnType<typeof resolveDiscordRuntimeGroupPolicy>;
function expectResolvedGroupPolicyCase(
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
) {
expect(resolved.groupPolicy).toBe(expected.groupPolicy);
expect(resolved.providerMissingFallbackApplied).toBe(expected.providerMissingFallbackApplied);
}
function expectAllowedZaloGroupAccess(params: Parameters<typeof evaluateZaloGroupAccess>[0]) {
expect(evaluateZaloGroupAccess(params)).toMatchObject({
allowed: true,
groupPolicy: "allowlist",
reason: "allowed",
});
}
function expectResolvedDiscordGroupPolicyCase(params: {
providerConfigPresent: Parameters<
typeof resolveDiscordRuntimeGroupPolicy
>[0]["providerConfigPresent"];
groupPolicy: Parameters<typeof resolveDiscordRuntimeGroupPolicy>[0]["groupPolicy"];
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">;
}) {
expectResolvedGroupPolicyCase(resolveDiscordRuntimeGroupPolicy(params), params.expected);
}
function expectAllowedZaloGroupAccessCase(
params: Omit<Parameters<typeof evaluateZaloGroupAccess>[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);
});
});
});

View File

@ -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<string, unknown>) {
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<string, unknown>;
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<typeof setActivePluginRegistry>[0];
expectedPlugin: ChannelPlugin;
}) {
setActivePluginRegistry(params.registry);
expect(await loadChannelPlugin("demo-loader")).toBe(params.expectedPlugin);
}
async function expectLoadedOutboundCase(params: {
registry: Parameters<typeof setActivePluginRegistry>[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<typeof setActivePluginRegistry>[0];
secondRegistry: Parameters<typeof setActivePluginRegistry>[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<typeof setActivePluginRegistry>[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<string, unknown>;
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<typeof authorizeConfigWrite>[0],
expected: ReturnType<typeof authorizeConfigWrite>,
) {
expect(authorizeConfigWrite(input)).toEqual(expected);
}
function expectResolvedConfigWriteTargetCase(pathSegments: readonly string[], expected: unknown) {
expect(resolveConfigWriteTargetFromPath([...pathSegments])).toEqual(expected);
}
function expectExplicitConfigWriteTargetCase(
input: Parameters<typeof resolveExplicitConfigWriteTarget>[0],
expected: ReturnType<typeof resolveExplicitConfigWriteTarget>,
) {
expect(resolveExplicitConfigWriteTarget(input)).toEqual(expected);
}
function expectFormattedDeniedMessage(
result: Exclude<ReturnType<typeof authorizeConfigWrite>, { 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);
});
});

View File

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

View File

@ -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<Parameters<typeof issuePairingChallenge>[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<Parameters<typeof issuePairingChallenge>[0], "sendPairingReply">;
expectedResult: Awaited<ReturnType<typeof issuePairingChallenge>>;
assertReply?: (sent: string[]) => void;
sendPairingReply?: Parameters<typeof issuePairingChallenge>[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());
});
});

View File

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

View File

@ -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<void> }) {
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<void>;
}) {
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<Record<string, unknown>>;
};
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<Record<string, unknown>>;
};
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([

View File

@ -14,6 +14,9 @@ let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js")
describe("pairing setup code", () => {
type ResolvedSetup = Awaited<ReturnType<typeof resolvePairingSetupFromConfig>>;
type ResolveSetupConfig = Parameters<typeof resolvePairingSetupFromConfig>[0];
type ResolveSetupOptions = Parameters<typeof resolvePairingSetupFromConfig>[1];
type ResolveSetupEnv = NonNullable<ResolveSetupOptions>["env"];
const defaultEnvSecretProviderConfig = {
secrets: {
providers: {
@ -32,6 +35,20 @@ describe("pairing setup code", () => {
id: "MISSING_GW_TOKEN",
};
function createCustomGatewayConfig(
auth: NonNullable<ResolveSetupConfig["gateway"]>["auth"],
config: Omit<ResolveSetupConfig, "gateway"> = {},
): 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<typeof vi.fn>;
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<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
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<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
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<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
}) {
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();
});
});