mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor(test): dedupe gateway and web scaffolding
This commit is contained in:
@@ -105,18 +105,23 @@ export function createDiscordDraftStream(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const readMessageId = () => streamMessageId;
|
||||
const clearMessageId = () => {
|
||||
streamMessageId = undefined;
|
||||
};
|
||||
const isValidStreamMessageId = (value: unknown): value is string => typeof value === "string";
|
||||
const deleteStreamMessage = async (messageId: string) => {
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
};
|
||||
|
||||
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
|
||||
throttleMs,
|
||||
state: streamState,
|
||||
sendOrEditStreamMessage,
|
||||
readMessageId: () => streamMessageId,
|
||||
clearMessageId: () => {
|
||||
streamMessageId = undefined;
|
||||
},
|
||||
isValidMessageId: (value): value is string => typeof value === "string",
|
||||
deleteMessage: async (messageId) => {
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
},
|
||||
readMessageId,
|
||||
clearMessageId,
|
||||
isValidMessageId: isValidStreamMessageId,
|
||||
deleteMessage: deleteStreamMessage,
|
||||
warn: params.warn,
|
||||
warnPrefix: "discord stream preview cleanup failed",
|
||||
});
|
||||
|
||||
@@ -19,6 +19,24 @@ type TestCase = {
|
||||
expected: ExpectedCredentialSet;
|
||||
};
|
||||
|
||||
const gatewayEnv = {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): OpenClawConfig {
|
||||
return {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote,
|
||||
auth: {
|
||||
token: "local-token",
|
||||
password: "local-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
|
||||
const keys = [
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
@@ -76,23 +94,11 @@ describe("gateway credential precedence parity", () => {
|
||||
},
|
||||
{
|
||||
name: "remote mode with remote token configured",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
token: "remote-token",
|
||||
password: "remote-password",
|
||||
},
|
||||
auth: {
|
||||
token: "local-token",
|
||||
password: "local-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
cfg: makeRemoteGatewayConfig({
|
||||
token: "remote-token",
|
||||
password: "remote-password",
|
||||
}),
|
||||
env: gatewayEnv,
|
||||
expected: {
|
||||
call: { token: "remote-token", password: "env-password" },
|
||||
probe: { token: "remote-token", password: "env-password" },
|
||||
@@ -102,22 +108,10 @@ describe("gateway credential precedence parity", () => {
|
||||
},
|
||||
{
|
||||
name: "remote mode without remote token keeps remote probe/status strict",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
password: "remote-password",
|
||||
},
|
||||
auth: {
|
||||
token: "local-token",
|
||||
password: "local-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
cfg: makeRemoteGatewayConfig({
|
||||
password: "remote-password",
|
||||
}),
|
||||
env: gatewayEnv,
|
||||
expected: {
|
||||
call: { token: "env-token", password: "env-password" },
|
||||
probe: { token: undefined, password: "env-password" },
|
||||
|
||||
@@ -9,20 +9,57 @@ function cfg(input: Partial<OpenClawConfig>): OpenClawConfig {
|
||||
return input as OpenClawConfig;
|
||||
}
|
||||
|
||||
type ResolveFromConfigInput = Parameters<typeof resolveGatewayCredentialsFromConfig>[0];
|
||||
type GatewayConfig = NonNullable<OpenClawConfig["gateway"]>;
|
||||
|
||||
const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" };
|
||||
const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" };
|
||||
const DEFAULT_GATEWAY_ENV = {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
function resolveGatewayCredentialsFor(
|
||||
gateway: GatewayConfig,
|
||||
overrides: Partial<Omit<ResolveFromConfigInput, "cfg" | "env">> = {},
|
||||
) {
|
||||
return resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({ gateway }),
|
||||
env: DEFAULT_GATEWAY_ENV,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function expectEnvGatewayCredentials(resolved: { token?: string; password?: string }) {
|
||||
expect(resolved).toEqual({
|
||||
token: "env-token",
|
||||
password: "env-password",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRemoteModeWithRemoteCredentials(
|
||||
overrides: Partial<Omit<ResolveFromConfigInput, "cfg" | "env">> = {},
|
||||
) {
|
||||
return resolveGatewayCredentialsFor(
|
||||
{
|
||||
mode: "remote",
|
||||
remote: DEFAULT_REMOTE_AUTH,
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
it("prefers explicit credentials over config and environment", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
explicitAuth: { token: "explicit-token", password: "explicit-password" },
|
||||
});
|
||||
const resolved = resolveGatewayCredentialsFor(
|
||||
{
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
},
|
||||
{
|
||||
explicitAuth: { token: "explicit-token", password: "explicit-password" },
|
||||
},
|
||||
);
|
||||
expect(resolved).toEqual({
|
||||
token: "explicit-token",
|
||||
password: "explicit-password",
|
||||
@@ -30,54 +67,27 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
});
|
||||
|
||||
it("returns empty credentials when url override is used without explicit auth", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
urlOverride: "wss://example.com",
|
||||
});
|
||||
const resolved = resolveGatewayCredentialsFor(
|
||||
{
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
},
|
||||
{
|
||||
urlOverride: "wss://example.com",
|
||||
},
|
||||
);
|
||||
expect(resolved).toEqual({});
|
||||
});
|
||||
|
||||
it("uses local-mode environment values before local config", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "env-token",
|
||||
password: "env-password",
|
||||
const resolved = resolveGatewayCredentialsFor({
|
||||
mode: "local",
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
});
|
||||
expectEnvGatewayCredentials(resolved);
|
||||
});
|
||||
|
||||
it("uses remote-mode remote credentials before env and local config", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { token: "remote-token", password: "remote-password" },
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const resolved = resolveRemoteModeWithRemoteCredentials();
|
||||
expect(resolved).toEqual({
|
||||
token: "remote-token",
|
||||
password: "env-password",
|
||||
@@ -85,38 +95,16 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
});
|
||||
|
||||
it("falls back to env/config when remote mode omits remote credentials", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {},
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "env-token",
|
||||
password: "env-password",
|
||||
const resolved = resolveGatewayCredentialsFor({
|
||||
mode: "remote",
|
||||
remote: {},
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
});
|
||||
expectEnvGatewayCredentials(resolved);
|
||||
});
|
||||
|
||||
it("supports env-first password override in remote mode for gateway call path", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { token: "remote-token", password: "remote-password" },
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
const resolved = resolveRemoteModeWithRemoteCredentials({
|
||||
remotePasswordPrecedence: "env-first",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
@@ -125,6 +113,34 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports env-first token precedence in remote mode", () => {
|
||||
const resolved = resolveRemoteModeWithRemoteCredentials({
|
||||
remoteTokenPrecedence: "env-first",
|
||||
remotePasswordPrecedence: "remote-first",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "env-token",
|
||||
password: "remote-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports remote-only password fallback for strict remote override call sites", () => {
|
||||
const resolved = resolveGatewayCredentialsFor(
|
||||
{
|
||||
mode: "remote",
|
||||
remote: { token: "remote-token" },
|
||||
auth: DEFAULT_GATEWAY_AUTH,
|
||||
},
|
||||
{
|
||||
remotePasswordFallback: "remote-only",
|
||||
},
|
||||
);
|
||||
expect(resolved).toEqual({
|
||||
token: "remote-token",
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports remote-only token fallback for strict remote override call sites", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createMSTeamsTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
@@ -130,7 +129,7 @@ describe("gateway hooks helpers", () => {
|
||||
{
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
plugin: createMSTeamsPlugin({ aliases: ["teams"] }),
|
||||
plugin: createMSTeamsTestPlugin({ aliases: ["teams"] }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -308,20 +307,3 @@ describe("gateway hooks helpers", () => {
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Bot Framework; enterprise support.",
|
||||
aliases: params.aliases,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,19 @@ const { sendGatewayAuthFailure } = await import("./http-common.js");
|
||||
const { getBearerToken } = await import("./http-utils.js");
|
||||
|
||||
describe("authorizeGatewayBearerRequestOrReply", () => {
|
||||
const bearerAuth = {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
password: undefined,
|
||||
allowTailscale: true,
|
||||
} satisfies ResolvedGatewayAuth;
|
||||
|
||||
const makeAuthorizeParams = () => ({
|
||||
req: {} as IncomingMessage,
|
||||
res: {} as ServerResponse,
|
||||
auth: bearerAuth,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -31,16 +44,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => {
|
||||
reason: "token_missing",
|
||||
});
|
||||
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
req: {} as IncomingMessage,
|
||||
res: {} as ServerResponse,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
password: undefined,
|
||||
allowTailscale: true,
|
||||
} satisfies ResolvedGatewayAuth,
|
||||
});
|
||||
const ok = await authorizeGatewayBearerRequestOrReply(makeAuthorizeParams());
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(
|
||||
@@ -55,16 +59,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => {
|
||||
vi.mocked(getBearerToken).mockReturnValue("abc");
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true, method: "token" });
|
||||
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
req: {} as IncomingMessage,
|
||||
res: {} as ServerResponse,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
password: undefined,
|
||||
allowTailscale: true,
|
||||
} satisfies ResolvedGatewayAuth,
|
||||
});
|
||||
const ok = await authorizeGatewayBearerRequestOrReply(makeAuthorizeParams());
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(
|
||||
|
||||
@@ -28,20 +28,26 @@ describe("gateway control-plane write rate limit", () => {
|
||||
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"];
|
||||
}
|
||||
|
||||
function buildConnect(): NonNullable<
|
||||
Parameters<typeof handleGatewayRequest>[0]["client"]
|
||||
>["connect"] {
|
||||
return {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: "ui",
|
||||
},
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClient() {
|
||||
return {
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: "ui",
|
||||
},
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
},
|
||||
connect: buildConnect(),
|
||||
connId: "conn-1",
|
||||
clientIp: "10.0.0.5",
|
||||
} as Parameters<typeof handleGatewayRequest>[0]["client"];
|
||||
@@ -127,18 +133,7 @@ describe("gateway control-plane write rate limit", () => {
|
||||
|
||||
it("uses connId fallback when both device and client IP are unknown", () => {
|
||||
const key = resolveControlPlaneRateLimitKey({
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: "ui",
|
||||
},
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
},
|
||||
connect: buildConnect(),
|
||||
connId: "conn-fallback",
|
||||
});
|
||||
expect(key).toBe("unknown-device|unknown-ip|conn=conn-fallback");
|
||||
@@ -146,18 +141,7 @@ describe("gateway control-plane write rate limit", () => {
|
||||
|
||||
it("keeps device/IP-based key when identity is present", () => {
|
||||
const key = resolveControlPlaneRateLimitKey({
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: "ui",
|
||||
},
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
},
|
||||
connect: buildConnect(),
|
||||
connId: "conn-fallback",
|
||||
clientIp: "10.0.0.10",
|
||||
});
|
||||
|
||||
@@ -126,6 +126,13 @@ const createStubChannelPlugin = (params: {
|
||||
},
|
||||
});
|
||||
|
||||
const defaultDirectChannelEntries = [
|
||||
{ id: "telegram", label: "Telegram" },
|
||||
{ id: "discord", label: "Discord" },
|
||||
{ id: "slack", label: "Slack" },
|
||||
{ id: "signal", label: "Signal" },
|
||||
] as const;
|
||||
|
||||
const defaultRegistry = createRegistry([
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
@@ -141,26 +148,11 @@ const defaultRegistry = createRegistry([
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
...defaultDirectChannelEntries.map((entry) => ({
|
||||
pluginId: entry.id,
|
||||
source: "test",
|
||||
plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
|
||||
},
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
|
||||
},
|
||||
{
|
||||
pluginId: "slack",
|
||||
source: "test",
|
||||
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
source: "test",
|
||||
plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
|
||||
},
|
||||
plugin: createStubChannelPlugin({ id: entry.id, label: entry.label }),
|
||||
})),
|
||||
]);
|
||||
|
||||
describe("gateway server agent", () => {
|
||||
|
||||
@@ -11,11 +11,12 @@ import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
|
||||
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectWebchatClient,
|
||||
connectOk,
|
||||
connectWebchatClient,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
rpcReq,
|
||||
startConnectedServerWithClient,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
trackConnectChallengeNonce,
|
||||
@@ -30,11 +31,10 @@ let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServerWithClient();
|
||||
const started = await startConnectedServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
port = started.port;
|
||||
await connectOk(ws);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -174,155 +174,138 @@ async function withCanvasGatewayHarness(params: {
|
||||
}
|
||||
|
||||
describe("gateway canvas host auth", () => {
|
||||
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
const tokenResolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
const withLoopbackTrustedProxy = async (run: () => Promise<void>, prefix?: string) => {
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
},
|
||||
},
|
||||
prefix: "openclaw-canvas-auth-test-",
|
||||
run: async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
const host = "127.0.0.1";
|
||||
const operatorOnlyCapability = "operator-only";
|
||||
const expiredNodeCapability = "expired-node";
|
||||
const activeNodeCapability = "active-node";
|
||||
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
|
||||
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
|
||||
...(prefix ? { prefix } : {}),
|
||||
run,
|
||||
});
|
||||
};
|
||||
|
||||
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(unauthCanvas.status).toBe(401);
|
||||
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
|
||||
await withLoopbackTrustedProxy(async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth: tokenResolvedAuth,
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
const host = "127.0.0.1";
|
||||
const operatorOnlyCapability = "operator-only";
|
||||
const expiredNodeCapability = "expired-node";
|
||||
const activeNodeCapability = "active-node";
|
||||
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
|
||||
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
|
||||
|
||||
const malformedScoped = await fetch(
|
||||
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
|
||||
);
|
||||
expect(malformedScoped.status).toBe(401);
|
||||
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(unauthCanvas.status).toBe(401);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-operator",
|
||||
clientIp: "192.168.1.10",
|
||||
role: "operator",
|
||||
mode: "backend",
|
||||
canvasCapability: operatorOnlyCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
const malformedScoped = await fetch(
|
||||
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
|
||||
);
|
||||
expect(malformedScoped.status).toBe(401);
|
||||
|
||||
const operatorCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(operatorCapabilityBlocked.status).toBe(401);
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-operator",
|
||||
clientIp: "192.168.1.10",
|
||||
role: "operator",
|
||||
mode: "backend",
|
||||
canvasCapability: operatorOnlyCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-expired-node",
|
||||
clientIp: "192.168.1.20",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: expiredNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() - 1,
|
||||
}),
|
||||
);
|
||||
const operatorCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(operatorCapabilityBlocked.status).toBe(401);
|
||||
|
||||
const expiredCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(expiredCapabilityBlocked.status).toBe(401);
|
||||
|
||||
const activeNodeClient = makeWsClient({
|
||||
connId: "c-active-node",
|
||||
clientIp: "192.168.1.30",
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-expired-node",
|
||||
clientIp: "192.168.1.20",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: activeNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
clients.add(activeNodeClient);
|
||||
canvasCapability: expiredNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() - 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
|
||||
expect(scopedCanvas.status).toBe(200);
|
||||
expect(await scopedCanvas.text()).toBe("ok");
|
||||
const expiredCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(expiredCapabilityBlocked.status).toBe(401);
|
||||
|
||||
const scopedA2ui = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
|
||||
);
|
||||
expect(scopedA2ui.status).toBe(200);
|
||||
const activeNodeClient = makeWsClient({
|
||||
connId: "c-active-node",
|
||||
clientIp: "192.168.1.30",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: activeNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
clients.add(activeNodeClient);
|
||||
|
||||
await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
|
||||
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
|
||||
expect(scopedCanvas.status).toBe(200);
|
||||
expect(await scopedCanvas.text()).toBe("ok");
|
||||
|
||||
clients.delete(activeNodeClient);
|
||||
const scopedA2ui = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
|
||||
);
|
||||
expect(scopedA2ui.status).toBe(200);
|
||||
|
||||
const disconnectedNodeBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${activeCanvasPath}`,
|
||||
);
|
||||
expect(disconnectedNodeBlocked.status).toBe(401);
|
||||
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
|
||||
|
||||
clients.delete(activeNodeClient);
|
||||
|
||||
const disconnectedNodeBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${activeCanvasPath}`,
|
||||
);
|
||||
expect(disconnectedNodeBlocked.status).toBe(401);
|
||||
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
|
||||
},
|
||||
});
|
||||
}, "openclaw-canvas-auth-test-");
|
||||
}, 60_000);
|
||||
|
||||
test("denies canvas auth when trusted proxy omits forwarded client headers", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
await withLoopbackTrustedProxy(async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth: tokenResolvedAuth,
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-loopback-node",
|
||||
clientIp: "127.0.0.1",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: "unused",
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
|
||||
},
|
||||
},
|
||||
run: async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-loopback-node",
|
||||
clientIp: "127.0.0.1",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: "unused",
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("accepts capability-scoped paths over IPv6 loopback", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
@@ -332,21 +315,9 @@ describe("gateway canvas host auth", () => {
|
||||
run: async () => {
|
||||
try {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
resolvedAuth: tokenResolvedAuth,
|
||||
listenHost: "::1",
|
||||
handleHttpRequest: async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (
|
||||
url.pathname !== CANVAS_HOST_PATH &&
|
||||
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
const capability = "ipv6-node";
|
||||
clients.add(
|
||||
@@ -380,54 +351,36 @@ describe("gateway canvas host auth", () => {
|
||||
}, 60_000);
|
||||
|
||||
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
await withLoopbackTrustedProxy(async () => {
|
||||
const rateLimiter = createAuthRateLimiter({
|
||||
maxAttempts: 1,
|
||||
windowMs: 60_000,
|
||||
lockoutMs: 60_000,
|
||||
exemptLoopback: false,
|
||||
});
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth: tokenResolvedAuth,
|
||||
rateLimiter,
|
||||
handleHttpRequest: async () => false,
|
||||
run: async ({ listener }) => {
|
||||
const headers = {
|
||||
authorization: "Bearer wrong",
|
||||
"x-forwarded-for": "203.0.113.99",
|
||||
};
|
||||
const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||
headers,
|
||||
});
|
||||
expect(first.status).toBe(401);
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||
headers,
|
||||
});
|
||||
expect(second.status).toBe(429);
|
||||
expect(second.headers.get("retry-after")).toBeTruthy();
|
||||
|
||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, headers, 429);
|
||||
},
|
||||
},
|
||||
run: async () => {
|
||||
const rateLimiter = createAuthRateLimiter({
|
||||
maxAttempts: 1,
|
||||
windowMs: 60_000,
|
||||
lockoutMs: 60_000,
|
||||
exemptLoopback: false,
|
||||
});
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
handleHttpRequest: async () => false,
|
||||
run: async ({ listener }) => {
|
||||
const headers = {
|
||||
authorization: "Bearer wrong",
|
||||
"x-forwarded-for": "203.0.113.99",
|
||||
};
|
||||
const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||
headers,
|
||||
});
|
||||
expect(first.status).toBe(401);
|
||||
|
||||
const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||
headers,
|
||||
});
|
||||
expect(second.status).toBe(429);
|
||||
expect(second.headers.get("retry-after")).toBeTruthy();
|
||||
|
||||
await expectWsRejected(
|
||||
`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`,
|
||||
headers,
|
||||
429,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js";
|
||||
import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
|
||||
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
@@ -16,34 +15,6 @@ let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const registryState = vi.hoisted(() => ({
|
||||
registry: {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
} as unknown as PluginRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("./server-plugins.js", async () => {
|
||||
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
|
||||
return {
|
||||
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
|
||||
setActivePluginRegistry(registryState.registry);
|
||||
return {
|
||||
pluginRegistry: registryState.registry,
|
||||
gatewayMethods: params.baseMethods ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const createStubChannelPlugin = (params: {
|
||||
id: ChannelPlugin["id"];
|
||||
label: string;
|
||||
@@ -131,11 +102,6 @@ afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function setRegistry(registry: PluginRegistry) {
|
||||
registryState.registry = registry;
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
describe("gateway server channels", () => {
|
||||
test("channels.status returns snapshot without probe", async () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
onceMessage,
|
||||
piSdkMock,
|
||||
rpcReq,
|
||||
startConnectedServerWithClient,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
@@ -35,19 +36,18 @@ let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: WebSocket;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
port = started.port;
|
||||
await connectOk(ws);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startConnectedServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
port = started.port;
|
||||
});
|
||||
|
||||
const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ deps, to, text }) => {
|
||||
|
||||
@@ -30,6 +30,15 @@ function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig {
|
||||
return {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("gateway session utils", () => {
|
||||
test("capArrayByJsonBytes trims from the front", () => {
|
||||
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
|
||||
@@ -243,12 +252,7 @@ describe("gateway session utils", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const cfg = createSingleAgentAvatarConfig(workspace);
|
||||
|
||||
const result = listAgentsForGateway(cfg);
|
||||
expect(result.agents[0]?.identity?.avatarUrl).toBeUndefined();
|
||||
@@ -265,12 +269,7 @@ describe("gateway session utils", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const cfg = createSingleAgentAvatarConfig(workspace);
|
||||
|
||||
const result = listAgentsForGateway(cfg);
|
||||
expect(result.agents[0]?.identity?.avatarUrl).toBe(
|
||||
|
||||
@@ -404,6 +404,15 @@ export async function startServerWithClient(
|
||||
return { server, ws, port, prevToken: prev, envSnapshot };
|
||||
}
|
||||
|
||||
export async function startConnectedServerWithClient(
|
||||
token?: string,
|
||||
opts?: GatewayServerOptions & { wsHeaders?: Record<string, string> },
|
||||
) {
|
||||
const started = await startServerWithClient(token, opts);
|
||||
await connectOk(started.ws);
|
||||
return started;
|
||||
}
|
||||
|
||||
type ConnectResponse = {
|
||||
type: "res";
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { sendMessage, sendPoll } from "./message.js";
|
||||
@@ -37,7 +37,7 @@ describe("sendMessage channel normalization", () => {
|
||||
{
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
plugin: createMSTeamsPlugin({
|
||||
plugin: createMSTeamsTestPlugin({
|
||||
outbound: createMSTeamsOutbound(),
|
||||
aliases: ["teams"],
|
||||
}),
|
||||
@@ -131,7 +131,7 @@ describe("sendPoll channel normalization", () => {
|
||||
{
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
plugin: createMSTeamsPlugin({
|
||||
plugin: createMSTeamsTestPlugin({
|
||||
aliases: ["teams"],
|
||||
outbound: createMSTeamsOutbound({ includePoll: true }),
|
||||
}),
|
||||
@@ -249,24 +249,3 @@ const createMattermostLikePlugin = (opts: {
|
||||
sendMedia: async () => ({ channel: "mattermost", messageId: "m2" }),
|
||||
},
|
||||
});
|
||||
|
||||
const createMSTeamsPlugin = (params: {
|
||||
aliases?: string[];
|
||||
outbound: ChannelOutboundAdapter;
|
||||
}): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Bot Framework; enterprise support.",
|
||||
aliases: params.aliases,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
outbound: params.outbound,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,21 @@ export const createMSTeamsTestPluginBase = (): Pick<
|
||||
};
|
||||
};
|
||||
|
||||
export const createMSTeamsTestPlugin = (params?: {
|
||||
aliases?: string[];
|
||||
outbound?: ChannelOutboundAdapter;
|
||||
}): ChannelPlugin => {
|
||||
const base = createMSTeamsTestPluginBase();
|
||||
return {
|
||||
...base,
|
||||
meta: {
|
||||
...base.meta,
|
||||
...(params?.aliases ? { aliases: params.aliases } : {}),
|
||||
},
|
||||
...(params?.outbound ? { outbound: params.outbound } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const createOutboundTestPlugin = (params: {
|
||||
id: ChannelId;
|
||||
outbound: ChannelOutboundAdapter;
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setLoggerOverride } from "../logging.js";
|
||||
import {
|
||||
createWebListenerFactoryCapture,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
@@ -60,18 +61,11 @@ describe("web auto-reply monitor logging", () => {
|
||||
const logPath = `/tmp/openclaw-log-test-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||
await monitorWebChannel(false, listenerFactory as never, false, resolver as never);
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver as never);
|
||||
const capturedOnMessage = capture.getOnMessage();
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import {
|
||||
createWebListenerFactoryCapture,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
@@ -250,15 +251,7 @@ describe("web auto-reply", () => {
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
@@ -269,7 +262,8 @@ describe("web auto-reply", () => {
|
||||
session: { store: store.storePath },
|
||||
}));
|
||||
|
||||
await monitorWebChannel(false, listenerFactory as never, false, resolver);
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver);
|
||||
const capturedOnMessage = capture.getOnMessage();
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
// Two messages from the same sender with fixed timestamps
|
||||
|
||||
Reference in New Issue
Block a user