mirror of https://github.com/openclaw/openclaw.git
772 lines
23 KiB
TypeScript
772 lines
23 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { clearPluginDiscoveryCache } from "../../../plugins/discovery.js";
|
|
import { clearPluginManifestRegistryCache } from "../../../plugins/manifest-registry.js";
|
|
import { setActivePluginRegistry } from "../../../plugins/runtime.js";
|
|
import {
|
|
createChannelTestPluginBase,
|
|
createOutboundTestPlugin,
|
|
createTestRegistry,
|
|
} from "../../../test-utils/channel-plugins.js";
|
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
|
|
import { listChannelPluginCatalogEntries } from "../catalog.js";
|
|
import {
|
|
authorizeConfigWrite,
|
|
canBypassConfigWritePolicy,
|
|
formatConfigWriteDeniedMessage,
|
|
resolveExplicitConfigWriteTarget,
|
|
resolveChannelConfigWrites,
|
|
resolveConfigWriteTargetFromPath,
|
|
} from "../config-writes.js";
|
|
import { listChannelPlugins } from "../index.js";
|
|
import { loadChannelPlugin } from "../load.js";
|
|
import { loadChannelOutboundAdapter } from "../outbound/load.js";
|
|
import type { ChannelOutboundAdapter, ChannelPlugin } from "../types.js";
|
|
|
|
describe("channel plugin registry", () => {
|
|
const emptyRegistry = createTestRegistry([]);
|
|
|
|
const createPlugin = (id: string, order?: number): ChannelPlugin => ({
|
|
id,
|
|
meta: {
|
|
id,
|
|
label: id,
|
|
selectionLabel: id,
|
|
docsPath: `/channels/${id}`,
|
|
blurb: "test",
|
|
...(order === undefined ? {} : { order }),
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
});
|
|
|
|
beforeEach(() => {
|
|
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.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"]);
|
|
},
|
|
},
|
|
{
|
|
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",
|
|
},
|
|
] as typeof registry.channels;
|
|
setActivePluginRegistry(registry, "registry-test");
|
|
|
|
expectListedChannelPluginIds(["demo-beta"]);
|
|
},
|
|
},
|
|
] as const)("$name", ({ run }) => {
|
|
expectRegistryActivationCase(run);
|
|
});
|
|
});
|
|
|
|
describe("channel plugin catalog", () => {
|
|
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,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function writeCatalogFile(catalogPath: string, entry: Record<string, unknown>) {
|
|
fs.writeFileSync(
|
|
catalogPath,
|
|
JSON.stringify({
|
|
entries: [entry],
|
|
}),
|
|
);
|
|
}
|
|
|
|
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: params.packageName,
|
|
openclaw: {
|
|
extensions: ["./index.js"],
|
|
channel: {
|
|
id: "demo-channel",
|
|
label: params.channelLabel,
|
|
selectionLabel: params.channelLabel,
|
|
docsPath: "/channels/demo-channel",
|
|
blurb: params.blurb,
|
|
},
|
|
install: {
|
|
npmSpec: params.packageName,
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.pluginId,
|
|
configSchema: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
|
|
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 } : {}),
|
|
}),
|
|
).toMatchObject(params.expected);
|
|
}
|
|
|
|
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" },
|
|
};
|
|
},
|
|
},
|
|
{
|
|
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 } : {}),
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|
|
|
|
const emptyRegistry = createTestRegistry([]);
|
|
|
|
const demoOutbound: ChannelOutboundAdapter = {
|
|
deliveryMode: "direct",
|
|
sendText: async () => ({ channel: "demo-loader", messageId: "m1" }),
|
|
sendMedia: async () => ({ channel: "demo-loader", messageId: "m2" }),
|
|
};
|
|
|
|
const demoLoaderPlugin: ChannelPlugin = {
|
|
...createChannelTestPluginBase({
|
|
id: "demo-loader",
|
|
label: "Demo Loader",
|
|
config: { listAccountIds: () => [], resolveAccount: () => ({}) },
|
|
}),
|
|
outbound: demoOutbound,
|
|
};
|
|
|
|
const registryWithDemoLoader = createTestRegistry([
|
|
{ pluginId: "demo-loader", plugin: demoLoaderPlugin, source: "test" },
|
|
]);
|
|
|
|
const demoOutboundV2: ChannelOutboundAdapter = {
|
|
deliveryMode: "direct",
|
|
sendText: async () => ({ channel: "demo-loader", messageId: "m3" }),
|
|
sendMedia: async () => ({ channel: "demo-loader", messageId: "m4" }),
|
|
};
|
|
|
|
const demoLoaderPluginV2 = createOutboundTestPlugin({
|
|
id: "demo-loader",
|
|
label: "Demo Loader",
|
|
outbound: demoOutboundV2,
|
|
});
|
|
|
|
const registryWithDemoLoaderV2 = createTestRegistry([
|
|
{ pluginId: "demo-loader", plugin: demoLoaderPluginV2, source: "test-v2" },
|
|
]);
|
|
|
|
const demoNoOutboundPlugin = createChannelTestPluginBase({
|
|
id: "demo-loader",
|
|
label: "Demo Loader",
|
|
});
|
|
|
|
const registryWithDemoLoaderNoOutbound = createTestRegistry([
|
|
{ pluginId: "demo-loader", plugin: demoNoOutboundPlugin, source: "test-no-outbound" },
|
|
]);
|
|
|
|
const demoOriginChannelId = "demo-origin";
|
|
const demoTargetChannelId = "demo-target";
|
|
|
|
function makeDemoConfigWritesCfg(accountIdKey: string) {
|
|
return {
|
|
channels: {
|
|
[demoOriginChannelId]: {
|
|
configWrites: true,
|
|
accounts: {
|
|
[accountIdKey]: { configWrites: false },
|
|
},
|
|
},
|
|
[demoTargetChannelId]: {
|
|
configWrites: true,
|
|
accounts: {
|
|
[accountIdKey]: { configWrites: false },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setActivePluginRegistry(emptyRegistry);
|
|
clearPluginDiscoveryCache();
|
|
clearPluginManifestRegistryCache();
|
|
});
|
|
|
|
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", () => {
|
|
function expectResolvedChannelConfigWrites(params: {
|
|
cfg: Record<string, unknown>;
|
|
channelId: string;
|
|
accountId?: string;
|
|
expected: boolean;
|
|
}) {
|
|
expect(
|
|
resolveChannelConfigWrites({
|
|
cfg: params.cfg,
|
|
channelId: params.channelId,
|
|
...(params.accountId ? { accountId: params.accountId } : {}),
|
|
}),
|
|
).toBe(params.expected);
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("authorizeConfigWrite", () => {
|
|
function expectConfigWriteBlocked(params: {
|
|
disabledAccountId: string;
|
|
reason: "target-disabled" | "origin-disabled";
|
|
blockedScope: "target" | "origin";
|
|
}) {
|
|
expect(
|
|
authorizeConfigWrite({
|
|
cfg: makeDemoConfigWritesCfg(params.disabledAccountId),
|
|
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
|
target: resolveExplicitConfigWriteTarget({
|
|
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
|
accountId: "work",
|
|
}),
|
|
}),
|
|
).toEqual({
|
|
allowed: false,
|
|
reason: params.reason,
|
|
blockedScope: {
|
|
kind: params.blockedScope,
|
|
scope: {
|
|
channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId,
|
|
accountId: params.blockedScope === "target" ? "work" : "default",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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",
|
|
},
|
|
{
|
|
name: "blocks when the origin account disables writes",
|
|
disabledAccountId: "default",
|
|
reason: "origin-disabled",
|
|
blockedScope: "origin",
|
|
},
|
|
] as const)("$name", (testCase) => {
|
|
expectConfigWriteBlocked(testCase);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "allows bypass for internal operator.admin writes",
|
|
input: {
|
|
cfg: makeDemoConfigWritesCfg("work"),
|
|
origin: { channelId: demoOriginChannelId, accountId: "default" },
|
|
target: resolveExplicitConfigWriteTarget({
|
|
channelId: demoTargetChannelId,
|
|
accountId: "work",
|
|
}),
|
|
allowBypass: canBypassConfigWritePolicy({
|
|
channel: INTERNAL_MESSAGE_CHANNEL,
|
|
gatewayClientScopes: ["operator.admin"],
|
|
}),
|
|
},
|
|
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"]),
|
|
},
|
|
expected: { allowed: true },
|
|
},
|
|
] as const)("$name", ({ input, expected }) => {
|
|
expectAuthorizedConfigWriteCase(input, expected);
|
|
});
|
|
|
|
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.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.each([
|
|
{
|
|
name: "formats denied messages consistently",
|
|
result: {
|
|
allowed: false,
|
|
reason: "target-disabled",
|
|
blockedScope: {
|
|
kind: "target",
|
|
scope: { channelId: demoTargetChannelId, accountId: "work" },
|
|
},
|
|
} as const,
|
|
},
|
|
] as const)("$name", ({ result }) => {
|
|
expectFormattedDeniedMessage(result);
|
|
});
|
|
});
|