mirror of https://github.com/openclaw/openclaw.git
test: dedupe pairing and channel contract suites
This commit is contained in:
parent
e7c1fcba0c
commit
f36354e401
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue