diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts new file mode 100644 index 000000000..187326c6b --- /dev/null +++ b/src/commands/channels/capabilities.test.ts @@ -0,0 +1,138 @@ +process.env.NO_COLOR = "1"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import { channelsCapabilitiesCommand } from "./capabilities.js"; +import { fetchSlackScopes } from "../../slack/scopes.js"; +import { + getChannelPlugin, + listChannelPlugins, +} from "../../channels/plugins/index.js"; + +const logs: string[] = []; +const errors: string[] = []; + +vi.mock("./shared.js", () => ({ + requireValidConfig: vi.fn(async () => ({ channels: {} })), + formatChannelAccountLabel: vi.fn(({ channel, accountId }: { channel: string; accountId: string }) => + `${channel}:${accountId}`, + ), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + listChannelPlugins: vi.fn(), + getChannelPlugin: vi.fn(), +})); + +vi.mock("../../slack/scopes.js", () => ({ + fetchSlackScopes: vi.fn(), +})); + +const runtime = { + log: (value: string) => logs.push(value), + error: (value: string) => errors.push(value), + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, +}; + +function resetOutput() { + logs.length = 0; + errors.length = 0; +} + +function buildPlugin(params: { + id: string; + capabilities?: ChannelPlugin["capabilities"]; + account?: Record; + probe?: unknown; +}): ChannelPlugin { + const capabilities = + params.capabilities ?? ({ chatTypes: ["direct"] } as ChannelPlugin["capabilities"]); + return { + id: params.id, + meta: { + id: params.id, + label: params.id, + selectionLabel: params.id, + docsPath: "/channels/test", + blurb: "test", + }, + capabilities, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => params.account ?? { accountId: "default" }, + defaultAccountId: () => "default", + isConfigured: () => true, + isEnabled: () => true, + }, + status: params.probe + ? { + probeAccount: async () => params.probe, + } + : undefined, + actions: { + listActions: () => ["poll"], + }, + }; +} + +describe("channelsCapabilitiesCommand", () => { + beforeEach(() => { + resetOutput(); + vi.clearAllMocks(); + }); + + it("prints Slack bot + user scopes when user token is configured", async () => { + const plugin = buildPlugin({ + id: "slack", + account: { + accountId: "default", + botToken: "xoxb-bot", + config: { userToken: "xoxp-user" }, + }, + probe: { ok: true, bot: { name: "clawdbot" }, team: { name: "team" } }, + }); + vi.mocked(listChannelPlugins).mockReturnValue([plugin]); + vi.mocked(getChannelPlugin).mockReturnValue(plugin); + vi.mocked(fetchSlackScopes).mockImplementation(async (token: string) => { + if (token === "xoxp-user") { + return { ok: true, scopes: ["users:read"], source: "auth.scopes" }; + } + return { ok: true, scopes: ["chat:write"], source: "auth.scopes" }; + }); + + await channelsCapabilitiesCommand({ channel: "slack" }, runtime); + + const output = logs.join("\n"); + expect(output).toContain("Bot scopes"); + expect(output).toContain("User scopes"); + expect(output).toContain("chat:write"); + expect(output).toContain("users:read"); + expect(fetchSlackScopes).toHaveBeenCalledWith("xoxb-bot", expect.any(Number)); + expect(fetchSlackScopes).toHaveBeenCalledWith("xoxp-user", expect.any(Number)); + }); + + it("prints Teams Graph permission hints when present", async () => { + const plugin = buildPlugin({ + id: "msteams", + probe: { + ok: true, + appId: "app-id", + graph: { + ok: true, + roles: ["ChannelMessage.Read.All", "Files.Read.All"], + }, + }, + }); + vi.mocked(listChannelPlugins).mockReturnValue([plugin]); + vi.mocked(getChannelPlugin).mockReturnValue(plugin); + + await channelsCapabilitiesCommand({ channel: "msteams" }, runtime); + + const output = logs.join("\n"); + expect(output).toContain("ChannelMessage.Read.All (channel history)"); + expect(output).toContain("Files.Read.All (files (OneDrive))"); + }); +});