Files
openclaw/src/commands/channels.status.command-flow.test.ts
2026-04-23 18:09:20 +01:00

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