import { beforeEach, describe, expect, it, vi } from "vitest"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; const mocks = vi.hoisted(() => ({ resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentId: vi.fn(), getChannelPluginCatalogEntry: vi.fn(), listChannelPluginCatalogEntries: vi.fn(), resolveChannelDefaultAccountId: vi.fn(), getChannelPlugin: vi.fn(), listChannelPlugins: vi.fn(), normalizeChannelId: vi.fn(), loadConfig: vi.fn(), readConfigFileSnapshot: vi.fn(), applyPluginAutoEnable: vi.fn(), replaceConfigFile: vi.fn(), setVerbose: vi.fn(), createClackPrompter: vi.fn(), ensureChannelSetupPluginInstalled: vi.fn(), loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), login: vi.fn(), logoutAccount: vi.fn(), resolveAccount: vi.fn(), })); vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, resolveDefaultAgentId: mocks.resolveDefaultAgentId, })); vi.mock("../channels/plugins/catalog.js", () => ({ getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry, listChannelPluginCatalogEntries: mocks.listChannelPluginCatalogEntries, })); vi.mock("../channels/plugins/helpers.js", () => ({ resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId, })); vi.mock("../channels/plugins/index.js", () => ({ getChannelPlugin: mocks.getChannelPlugin, listChannelPlugins: mocks.listChannelPlugins, normalizeChannelId: mocks.normalizeChannelId, })); vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig, readConfigFileSnapshot: mocks.readConfigFileSnapshot, replaceConfigFile: mocks.replaceConfigFile, })); vi.mock("../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: mocks.applyPluginAutoEnable, })); vi.mock("../globals.js", () => ({ setVerbose: mocks.setVerbose, })); vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled, loadChannelSetupPluginRegistrySnapshotForChannel: mocks.loadChannelSetupPluginRegistrySnapshotForChannel, })); describe("channel-auth", () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const plugin = { id: "whatsapp", auth: { login: mocks.login }, gateway: { logoutAccount: mocks.logoutAccount }, config: { listAccountIds: vi.fn().mockReturnValue(["default"]), resolveAccount: mocks.resolveAccount, }, }; beforeEach(() => { vi.clearAllMocks(); mocks.normalizeChannelId.mockReturnValue("whatsapp"); mocks.getChannelPlugin.mockReturnValue(plugin); mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); mocks.listChannelPluginCatalogEntries.mockReturnValue([]); mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {} } }); mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); mocks.replaceConfigFile.mockResolvedValue(undefined); mocks.listChannelPlugins.mockReturnValue([plugin]); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account"); mocks.createClackPrompter.mockReturnValue({} as object); mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({ cfg: { channels: { whatsapp: {} } }, installed: true, pluginId: "whatsapp", }); mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ channels: [{ plugin }], channelSetups: [], }); mocks.resolveAccount.mockReturnValue({ id: "resolved-account" }); mocks.login.mockResolvedValue(undefined); mocks.logoutAccount.mockResolvedValue(undefined); }); it("runs login with explicit trimmed account and verbose flag", async () => { await runChannelLogin({ channel: "wa", account: " acct-1 ", verbose: true }, runtime); expect(mocks.setVerbose).toHaveBeenCalledWith(true); expect(mocks.resolveChannelDefaultAccountId).not.toHaveBeenCalled(); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ cfg: { channels: { whatsapp: {} } }, accountId: "acct-1", runtime, verbose: true, channelInput: "wa", }), ); }); it("auto-picks the single configured channel that supports login when opts are empty", async () => { await runChannelLogin({}, runtime); expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ channelInput: "whatsapp", }), ); }); it("does not auto-pick enabled-only channel stubs when channel is omitted", async () => { mocks.loadConfig.mockReturnValue({ channels: { whatsapp: { enabled: false } } }); await expect(runChannelLogin({}, runtime)).rejects.toThrow( "Channel is required (no configured channels support login).", ); expect(mocks.login).not.toHaveBeenCalled(); }); it("auto-picks the single auth-capable channel from the auto-enabled config snapshot", async () => { const autoEnabledCfg = { channels: { whatsapp: {} }, plugins: { allow: ["whatsapp"] } }; mocks.loadConfig.mockReturnValue({}); mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledCfg, changes: ["whatsapp"] }); await runChannelLogin({}, runtime); expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ config: {}, env: process.env, }); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ cfg: autoEnabledCfg, channelInput: "whatsapp", }), ); expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ nextConfig: autoEnabledCfg, baseHash: "config-1", }); }); it("persists auto-enabled config during logout auto-pick too", async () => { const autoEnabledCfg = { channels: { whatsapp: {} }, plugins: { allow: ["whatsapp"] } }; mocks.loadConfig.mockReturnValue({}); mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledCfg, changes: ["whatsapp"] }); await runChannelLogout({}, runtime); expect(mocks.logoutAccount).toHaveBeenCalledWith( expect.objectContaining({ cfg: autoEnabledCfg, }), ); expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ nextConfig: autoEnabledCfg, baseHash: "config-1", }); }); it("ignores configured channels that do not support login when channel is omitted", async () => { const telegramPlugin = { id: "telegram", auth: {}, gateway: {}, config: { listAccountIds: vi.fn().mockReturnValue(["default"]), resolveAccount: vi.fn().mockReturnValue({ enabled: true }), }, }; mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, telegram: {} } }); mocks.listChannelPlugins.mockReturnValue([telegramPlugin, plugin]); await runChannelLogin({}, runtime); expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); expect(mocks.login).toHaveBeenCalled(); }); it("propagates auth-channel ambiguity when multiple configured channels support login", async () => { const zaloPlugin = { id: "zalouser", auth: { login: vi.fn() }, gateway: {}, config: { listAccountIds: vi.fn().mockReturnValue(["default"]), resolveAccount: vi.fn().mockReturnValue({ enabled: true }), }, }; mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, zalouser: {} } }); mocks.listChannelPlugins.mockReturnValue([plugin, zaloPlugin]); mocks.normalizeChannelId.mockImplementation((value) => value); mocks.getChannelPlugin.mockImplementation((value) => value === "whatsapp" ? plugin : value === "zalouser" ? (zaloPlugin as typeof plugin) : undefined, ); await expect(runChannelLogin({}, runtime)).rejects.toThrow( "multiple configured channels support login: whatsapp, zalouser", ); expect(mocks.login).not.toHaveBeenCalled(); }); it("ignores plugins with prototype-chain IDs like __proto__", async () => { const protoPlugin = { id: "__proto__", auth: { login: vi.fn() }, gateway: {}, config: { listAccountIds: vi.fn().mockReturnValue(["default"]), resolveAccount: vi.fn().mockReturnValue({ enabled: true }), }, }; mocks.listChannelPlugins.mockReturnValue([protoPlugin, plugin]); await runChannelLogin({}, runtime); expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); expect(mocks.login).toHaveBeenCalled(); }); it("throws for unsupported channel aliases", async () => { mocks.normalizeChannelId.mockImplementation(() => undefined); await expect(runChannelLogin({ channel: "bad-channel" }, runtime)).rejects.toThrow( "Unsupported channel: bad-channel", ); expect(mocks.login).not.toHaveBeenCalled(); }); it("throws when channel does not support login", async () => { mocks.getChannelPlugin.mockReturnValueOnce({ auth: {}, gateway: { logoutAccount: mocks.logoutAccount }, config: { resolveAccount: mocks.resolveAccount }, }); await expect(runChannelLogin({ channel: "whatsapp" }, runtime)).rejects.toThrow( "Channel whatsapp does not support login", ); }); it("installs a catalog-backed channel plugin on demand for login", async () => { const catalogEntry = { id: "whatsapp", pluginId: "@openclaw/whatsapp", meta: { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp", docsPath: "/channels/whatsapp", blurb: "wa", }, install: { npmSpec: "@openclaw/whatsapp", }, }; mocks.getChannelPlugin.mockReturnValueOnce(undefined); mocks.listChannelPluginCatalogEntries.mockReturnValueOnce([catalogEntry]); mocks.loadChannelSetupPluginRegistrySnapshotForChannel .mockReturnValueOnce({ channels: [], channelSetups: [], }) .mockReturnValueOnce({ channels: [{ plugin }], channelSetups: [], }); await runChannelLogin({ channel: "whatsapp" }, runtime); expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( expect.objectContaining({ entry: catalogEntry, runtime, workspaceDir: "/tmp/workspace", }), ); expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ channel: "whatsapp", pluginId: "whatsapp", workspaceDir: "/tmp/workspace", }), ); expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ nextConfig: { channels: { whatsapp: {} } }, baseHash: "config-1", }); expect(mocks.login).toHaveBeenCalled(); }); it("resolves explicit channel login through the catalog when registry normalize misses", async () => { mocks.normalizeChannelId.mockReturnValueOnce(undefined).mockReturnValue("whatsapp"); mocks.getChannelPlugin.mockReturnValueOnce(undefined); mocks.listChannelPluginCatalogEntries.mockReturnValueOnce([ { id: "whatsapp", pluginId: "@openclaw/whatsapp", meta: { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp", docsPath: "/channels/whatsapp", blurb: "wa", }, install: { npmSpec: "@openclaw/whatsapp", }, }, ]); mocks.loadChannelSetupPluginRegistrySnapshotForChannel .mockReturnValueOnce({ channels: [], channelSetups: [], }) .mockReturnValueOnce({ channels: [{ plugin }], channelSetups: [], }); await runChannelLogin({ channel: "whatsapp" }, runtime); expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( expect.objectContaining({ entry: expect.objectContaining({ id: "whatsapp" }), runtime, workspaceDir: "/tmp/workspace", }), ); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ channelInput: "whatsapp", }), ); }); it("runs logout with resolved account and explicit account id", async () => { await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime); expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: { whatsapp: {} } }, "acct-2"); expect(mocks.logoutAccount).toHaveBeenCalledWith({ cfg: { channels: { whatsapp: {} } }, accountId: "acct-2", account: { id: "resolved-account" }, runtime, }); expect(mocks.setVerbose).not.toHaveBeenCalled(); }); it("throws when channel does not support logout", async () => { mocks.getChannelPlugin.mockReturnValueOnce({ auth: { login: mocks.login }, gateway: {}, config: { resolveAccount: mocks.resolveAccount }, }); await expect(runChannelLogout({ channel: "whatsapp" }, runtime)).rejects.toThrow( "Channel whatsapp does not support logout", ); }); });