mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 15:20:42 +00:00
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
|
import { channelsStatusCommand } from "./channels/status.js";
|
|
import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js";
|
|
|
|
const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID;
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
callGateway: vi.fn(),
|
|
resolveCommandConfigWithSecrets: vi.fn(),
|
|
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
|
|
requireValidConfigSnapshot: vi.fn(),
|
|
listChannelPlugins: vi.fn(),
|
|
listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]),
|
|
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
|
|
}));
|
|
|
|
vi.mock("../gateway/call.js", () => ({
|
|
callGateway: (opts: unknown) => mocks.callGateway(opts),
|
|
}));
|
|
|
|
vi.mock("../cli/command-config-resolution.js", () => ({
|
|
resolveCommandConfigWithSecrets: async (opts: {
|
|
runtime?: { log: (message: string) => void };
|
|
}) => {
|
|
const result = await mocks.resolveCommandConfigWithSecrets(opts);
|
|
for (const entry of result?.diagnostics ?? []) {
|
|
opts.runtime?.log(`[secrets] ${entry}`);
|
|
}
|
|
return result;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../config/config.js", () => ({
|
|
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
|
|
}));
|
|
|
|
vi.mock("../plugins/channel-plugin-ids.js", () => ({
|
|
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
|
|
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
|
|
}));
|
|
|
|
vi.mock("./channels/shared.js", () => ({
|
|
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
|
|
formatChannelAccountLabel: ({
|
|
channel,
|
|
accountId,
|
|
}: {
|
|
channel: string;
|
|
accountId: string;
|
|
name?: string;
|
|
}) => `${channel} ${accountId}`,
|
|
appendEnabledConfiguredLinkedBits: (bits: string[], account: Record<string, unknown>) => {
|
|
if (typeof account.enabled === "boolean") {
|
|
bits.push(account.enabled ? "enabled" : "disabled");
|
|
}
|
|
if (account.configured === true) {
|
|
bits.push("configured");
|
|
if (Object.values(account).includes("configured_unavailable")) {
|
|
bits.push("secret unavailable in this command path");
|
|
}
|
|
}
|
|
},
|
|
appendModeBit: (bits: string[], account: Record<string, unknown>) => {
|
|
if (typeof account.mode === "string" && account.mode.length > 0) {
|
|
bits.push(`mode:${account.mode}`);
|
|
}
|
|
},
|
|
appendTokenSourceBits: (bits: string[], account: Record<string, unknown>) => {
|
|
if (account.tokenSource === "config") {
|
|
const unavailable = account.tokenStatus === "configured_unavailable" ? " (unavailable)" : "";
|
|
bits.push(`token:config${unavailable}`);
|
|
}
|
|
},
|
|
appendBaseUrlBit: (bits: string[], account: Record<string, unknown>) => {
|
|
if (typeof account.baseUrl === "string" && account.baseUrl) {
|
|
bits.push(`url:${account.baseUrl}`);
|
|
}
|
|
},
|
|
buildChannelAccountLine: (channel: string, account: Record<string, unknown>, bits: string[]) => {
|
|
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
|
|
return `- ${channel} ${accountId}: ${bits.join(", ")}`;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../channels/plugins/index.js", () => ({
|
|
listChannelPlugins: () => mocks.listChannelPlugins(),
|
|
getChannelPlugin: (channel: string) =>
|
|
(mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
|
|
}));
|
|
|
|
vi.mock("../channels/plugins/read-only.js", () => ({
|
|
listReadOnlyChannelPluginsForConfig: () => mocks.listChannelPlugins(),
|
|
}));
|
|
|
|
vi.mock("../channels/account-snapshot-fields.js", () => ({
|
|
hasConfiguredUnavailableCredentialStatus: (account: Record<string, unknown>) =>
|
|
Object.values(account).includes("configured_unavailable"),
|
|
hasResolvedCredentialValue: (account: Record<string, unknown>) =>
|
|
["token", "botToken", "appToken", "signingSecret"].some(
|
|
(key) => typeof account[key] === "string" && account[key].length > 0,
|
|
),
|
|
}));
|
|
|
|
vi.mock("../channels/plugins/status.js", () => ({
|
|
buildReadOnlySourceChannelAccountSnapshot: async ({
|
|
plugin,
|
|
cfg,
|
|
accountId,
|
|
}: {
|
|
plugin: ReturnType<typeof createTokenOnlyPlugin>;
|
|
cfg: { secretResolved?: boolean };
|
|
accountId: string;
|
|
}) => ({
|
|
accountId,
|
|
...plugin.config.inspectAccount(cfg),
|
|
}),
|
|
buildChannelAccountSnapshot: async ({
|
|
plugin,
|
|
cfg,
|
|
accountId,
|
|
}: {
|
|
plugin: ReturnType<typeof createTokenOnlyPlugin>;
|
|
cfg: { secretResolved?: boolean };
|
|
accountId: string;
|
|
}) => ({
|
|
accountId,
|
|
...plugin.config.resolveAccount(cfg),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../cli/command-secret-targets.js", () => ({
|
|
getConfiguredChannelsCommandSecretTargetIds: () => [],
|
|
}));
|
|
|
|
vi.mock("../infra/channels-status-issues.js", () => ({
|
|
collectChannelStatusIssues: () => [],
|
|
}));
|
|
|
|
vi.mock("../cli/progress.js", () => ({
|
|
withProgress: (opts: unknown, run: () => Promise<unknown>) => mocks.withProgress(opts, run),
|
|
}));
|
|
|
|
function createTokenAccountSnapshot(cfg: { secretResolved?: boolean }) {
|
|
return {
|
|
name: "Primary",
|
|
enabled: true,
|
|
configured: true,
|
|
token: cfg.secretResolved ? "resolved-discord-token" : "",
|
|
tokenSource: "config",
|
|
tokenStatus: cfg.secretResolved ? "available" : "configured_unavailable",
|
|
};
|
|
}
|
|
|
|
function createTokenOnlyPlugin() {
|
|
return {
|
|
id: "discord",
|
|
meta: {
|
|
id: "discord",
|
|
label: "Discord",
|
|
selectionLabel: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "test",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
defaultAccountId: resolveDefaultAccountId,
|
|
inspectAccount: createTokenAccountSnapshot,
|
|
resolveAccount: createTokenAccountSnapshot,
|
|
isConfigured: () => true,
|
|
isEnabled: () => true,
|
|
},
|
|
actions: {
|
|
describeMessageTool: () => ({ actions: ["send"] }),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("channelsStatusCommand SecretRef fallback flow", () => {
|
|
beforeEach(() => {
|
|
mocks.callGateway.mockReset();
|
|
mocks.resolveCommandConfigWithSecrets.mockReset();
|
|
mocks.readConfigFileSnapshot.mockClear();
|
|
mocks.requireValidConfigSnapshot.mockReset();
|
|
mocks.listChannelPlugins.mockReset();
|
|
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
|
|
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
|
|
mocks.withProgress.mockClear();
|
|
mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
|
|
});
|
|
|
|
it("keeps read-only fallback output when SecretRefs are unresolved", async () => {
|
|
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
|
|
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
|
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
|
|
resolvedConfig: { secretResolved: false, channels: {} },
|
|
effectiveConfig: { secretResolved: false, channels: {} },
|
|
diagnostics: [
|
|
"channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.",
|
|
],
|
|
});
|
|
const { runtime, logs, errors } = createCapturingTestRuntime();
|
|
|
|
await channelsStatusCommand({ probe: false }, runtime as never);
|
|
|
|
expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true);
|
|
expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
commandName: "channels status",
|
|
mode: "read_only_status",
|
|
}),
|
|
);
|
|
expect(
|
|
logs.some((line) =>
|
|
line.includes("[secrets] channels status: channels.discord.token is unavailable"),
|
|
),
|
|
).toBe(true);
|
|
const joined = logs.join("\n");
|
|
expect(joined).toContain("configured, secret unavailable in this command path");
|
|
expect(joined).toContain("token:config (unavailable)");
|
|
});
|
|
|
|
it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => {
|
|
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
|
|
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
|
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
|
|
resolvedConfig: { secretResolved: true, channels: {} },
|
|
effectiveConfig: { secretResolved: true, channels: {} },
|
|
diagnostics: [],
|
|
});
|
|
const { runtime, logs } = createCapturingTestRuntime();
|
|
|
|
await channelsStatusCommand({ probe: false }, runtime as never);
|
|
|
|
const joined = logs.join("\n");
|
|
expect(joined).toContain("configured");
|
|
expect(joined).toContain("token:config");
|
|
expect(joined).not.toContain("secret unavailable in this command path");
|
|
expect(joined).not.toContain("token:config (unavailable)");
|
|
});
|
|
|
|
it("keeps JSON fallback structured without rendering config-only text", async () => {
|
|
mocks.callGateway.mockRejectedValue(
|
|
new Error(
|
|
[
|
|
"gateway timeout after 3000ms",
|
|
"Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible",
|
|
"Gateway fallback: (wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)",
|
|
"Source: env OPENCLAW_GATEWAY_URL",
|
|
].join("\n"),
|
|
),
|
|
);
|
|
mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
|
|
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
|
|
resolvedConfig: { secretResolved: true, channels: {} },
|
|
effectiveConfig: { secretResolved: true, channels: {} },
|
|
diagnostics: [],
|
|
});
|
|
const { runtime, logs, errors } = createCapturingTestRuntime();
|
|
|
|
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
|
|
|
|
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
|
|
expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({ secretResolved: true }),
|
|
includePersistedAuthState: false,
|
|
}),
|
|
);
|
|
const payload = JSON.parse(logs.at(-1) ?? "{}");
|
|
expect(errors.join("\n")).not.toContain("user:pass");
|
|
expect(errors.join("\n")).not.toContain("secret-token");
|
|
expect(errors.join("\n")).not.toContain("fallback-user:fallback-pass");
|
|
expect(errors.join("\n")).not.toContain("fallback-secret");
|
|
expect(payload.error).toContain("Gateway target:");
|
|
expect(payload.error).not.toContain("user:pass");
|
|
expect(payload.error).not.toContain("secret-token");
|
|
expect(payload.error).not.toContain("fallback-user:fallback-pass");
|
|
expect(payload.error).not.toContain("fallback-secret");
|
|
expect(payload).toEqual(
|
|
expect.objectContaining({
|
|
gatewayReachable: false,
|
|
configOnly: true,
|
|
configuredChannels: ["discord"],
|
|
}),
|
|
);
|
|
});
|
|
});
|