|
| 1 | +// Tests channel-native Codex login command routing and pairing-code delivery. |
| 2 | +import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | +import type { ModelsAuthLoginFlowOptions } from "../../commands/models/auth.js"; |
| 4 | +import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| 5 | +import { buildBuiltinChatCommands } from "../commands-registry.shared.js"; |
| 6 | +import type { HandleCommandsParams } from "./commands-types.js"; |
| 7 | +import { buildCommandTestParams } from "./commands.test-harness.js"; |
| 8 | + |
| 9 | +const runModelsAuthLoginFlowMock = vi.hoisted(() => vi.fn()); |
| 10 | + |
| 11 | +vi.mock("../../commands/models/auth.js", () => ({ |
| 12 | + runModelsAuthLoginFlow: (opts: unknown) => runModelsAuthLoginFlowMock(opts), |
| 13 | +})); |
| 14 | + |
| 15 | +const { handleLoginCommand, testing } = await import("./commands-login.js"); |
| 16 | +const { loadCommandHandlers } = await import("./commands-handlers.runtime.js"); |
| 17 | + |
| 18 | +function buildLoginParams( |
| 19 | + commandBody: string, |
| 20 | + overrides: { |
| 21 | + command?: Partial<HandleCommandsParams["command"]>; |
| 22 | + ctx?: Partial<HandleCommandsParams["ctx"]>; |
| 23 | + opts?: HandleCommandsParams["opts"]; |
| 24 | + sessionKey?: string; |
| 25 | + agentId?: string; |
| 26 | + } = {}, |
| 27 | +): HandleCommandsParams { |
| 28 | + const params = buildCommandTestParams( |
| 29 | + commandBody, |
| 30 | + { |
| 31 | + commands: { text: true }, |
| 32 | + channels: { slack: { allowFrom: ["owner"] } }, |
| 33 | + session: { mainKey: "main" }, |
| 34 | + } as OpenClawConfig, |
| 35 | + { |
| 36 | + Provider: "slack", |
| 37 | + Surface: "slack", |
| 38 | + OriginatingChannel: "slack", |
| 39 | + OriginatingTo: "channel:C123", |
| 40 | + AccountId: "workspace-a", |
| 41 | + MessageThreadId: "thread-1", |
| 42 | + ...overrides.ctx, |
| 43 | + }, |
| 44 | + { workspaceDir: "/tmp/openclaw-login-test" }, |
| 45 | + ); |
| 46 | + params.sessionKey = overrides.sessionKey ?? "agent:main:slack:channel:C123"; |
| 47 | + params.agentId = overrides.agentId; |
| 48 | + params.command = { |
| 49 | + ...params.command, |
| 50 | + channel: "slack", |
| 51 | + channelId: "slack", |
| 52 | + accountId: "workspace-a", |
| 53 | + senderId: "owner", |
| 54 | + senderIsOwner: true, |
| 55 | + isAuthorizedSender: true, |
| 56 | + from: "slack:owner", |
| 57 | + to: "channel:C123", |
| 58 | + ...overrides.command, |
| 59 | + }; |
| 60 | + params.opts = overrides.opts; |
| 61 | + return params; |
| 62 | +} |
| 63 | + |
| 64 | +function mockSuccessfulLoginFlow(): void { |
| 65 | + runModelsAuthLoginFlowMock.mockImplementation(async (opts: ModelsAuthLoginFlowOptions) => { |
| 66 | + await opts.prompter.note?.( |
| 67 | + "Open https://auth.openai.com/device and enter code ABCD-EFGH. Never share this code.", |
| 68 | + "Codex login", |
| 69 | + ); |
| 70 | + return { |
| 71 | + providerId: "openai", |
| 72 | + methodId: "device-code", |
| 73 | + profiles: [{ profileId: "openai:owner", provider: "openai", mode: "oauth" }], |
| 74 | + }; |
| 75 | + }); |
| 76 | +} |
| 77 | + |
| 78 | +describe("handleLoginCommand", () => { |
| 79 | + beforeEach(() => { |
| 80 | + vi.clearAllMocks(); |
| 81 | + testing.clearActiveFlows(); |
| 82 | + }); |
| 83 | + |
| 84 | + it("registers /login as a built-in command handler", () => { |
| 85 | + expect(buildBuiltinChatCommands().find((entry) => entry.key === "login")).toMatchObject({ |
| 86 | + nativeName: "login", |
| 87 | + textAliases: ["/login"], |
| 88 | + scope: "both", |
| 89 | + }); |
| 90 | + expect(loadCommandHandlers()).toContain(handleLoginCommand); |
| 91 | + }); |
| 92 | + |
| 93 | + it("starts Codex device-code login and emits the pairing code through block delivery", async () => { |
| 94 | + const onBlockReply = vi.fn(async () => {}); |
| 95 | + mockSuccessfulLoginFlow(); |
| 96 | + |
| 97 | + const result = await handleLoginCommand( |
| 98 | + buildLoginParams("/login codex", { opts: { onBlockReply } }), |
| 99 | + true, |
| 100 | + ); |
| 101 | + |
| 102 | + expect(result).toEqual({ |
| 103 | + shouldContinue: false, |
| 104 | + reply: { text: "Codex login complete. Try your request again now." }, |
| 105 | + }); |
| 106 | + expect(onBlockReply).toHaveBeenCalledWith( |
| 107 | + expect.objectContaining({ |
| 108 | + text: expect.stringContaining("ABCD-EFGH"), |
| 109 | + }), |
| 110 | + ); |
| 111 | + expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith( |
| 112 | + expect.objectContaining({ |
| 113 | + provider: "openai", |
| 114 | + method: "device-code", |
| 115 | + agent: "main", |
| 116 | + isRemote: true, |
| 117 | + }), |
| 118 | + ); |
| 119 | + }); |
| 120 | + |
| 121 | + it.each(["web", "discord", "slack"] as const)( |
| 122 | + "supports /login codex on the %s command surface", |
| 123 | + async (surface) => { |
| 124 | + const onBlockReply = vi.fn(async () => {}); |
| 125 | + mockSuccessfulLoginFlow(); |
| 126 | + |
| 127 | + const result = await handleLoginCommand( |
| 128 | + buildLoginParams("/login codex", { |
| 129 | + ctx: { |
| 130 | + Provider: surface, |
| 131 | + Surface: surface, |
| 132 | + OriginatingChannel: surface, |
| 133 | + OriginatingTo: `${surface}:conversation-1`, |
| 134 | + }, |
| 135 | + command: { |
| 136 | + channel: surface, |
| 137 | + channelId: surface, |
| 138 | + to: `${surface}:conversation-1`, |
| 139 | + }, |
| 140 | + opts: { onBlockReply }, |
| 141 | + }), |
| 142 | + true, |
| 143 | + ); |
| 144 | + |
| 145 | + expect(result?.reply?.text).toBe("Codex login complete. Try your request again now."); |
| 146 | + expect(onBlockReply).toHaveBeenCalledWith( |
| 147 | + expect.objectContaining({ |
| 148 | + text: expect.stringContaining("https://auth.openai.com/device"), |
| 149 | + }), |
| 150 | + ); |
| 151 | + }, |
| 152 | + ); |
| 153 | + |
| 154 | + it("falls back to returning the pairing message when no block dispatcher is available", async () => { |
| 155 | + mockSuccessfulLoginFlow(); |
| 156 | + |
| 157 | + const result = await handleLoginCommand(buildLoginParams("/login openai"), true); |
| 158 | + |
| 159 | + expect(result?.reply?.text).toContain("ABCD-EFGH"); |
| 160 | + expect(result?.reply?.text).toContain("Codex login complete"); |
| 161 | + }); |
| 162 | + |
| 163 | + it("dedupes an active flow for the same channel thread and provider", async () => { |
| 164 | + let resolveLogin!: () => void; |
| 165 | + runModelsAuthLoginFlowMock.mockImplementation( |
| 166 | + () => |
| 167 | + new Promise((resolve) => { |
| 168 | + resolveLogin = () => |
| 169 | + resolve({ |
| 170 | + providerId: "openai", |
| 171 | + methodId: "device-code", |
| 172 | + profiles: [], |
| 173 | + }); |
| 174 | + }), |
| 175 | + ); |
| 176 | + |
| 177 | + const first = handleLoginCommand(buildLoginParams("/login codex"), true); |
| 178 | + const second = await handleLoginCommand(buildLoginParams("/login codex"), true); |
| 179 | + |
| 180 | + expect(second).toEqual({ |
| 181 | + shouldContinue: false, |
| 182 | + reply: { |
| 183 | + text: "A Codex login code is already active for this chat or channel. Complete it, or wait for it to expire before requesting a new one.", |
| 184 | + }, |
| 185 | + }); |
| 186 | + resolveLogin(); |
| 187 | + await first; |
| 188 | + }); |
| 189 | + |
| 190 | + it("rejects non-owner senders before starting login", async () => { |
| 191 | + const result = await handleLoginCommand( |
| 192 | + buildLoginParams("/login codex", { |
| 193 | + command: { senderIsOwner: false }, |
| 194 | + }), |
| 195 | + true, |
| 196 | + ); |
| 197 | + |
| 198 | + expect(result).toEqual({ |
| 199 | + shouldContinue: false, |
| 200 | + reply: { text: "Only an OpenClaw owner/admin can start Codex login from this channel." }, |
| 201 | + }); |
| 202 | + expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); |
| 203 | + }); |
| 204 | + |
| 205 | + it("normalizes Codex login aliases to the OpenAI provider", async () => { |
| 206 | + mockSuccessfulLoginFlow(); |
| 207 | + |
| 208 | + await handleLoginCommand(buildLoginParams("/login openai-codex"), true); |
| 209 | + |
| 210 | + expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith( |
| 211 | + expect.objectContaining({ provider: "openai" }), |
| 212 | + ); |
| 213 | + }); |
| 214 | + |
| 215 | + it("returns a friendly error for unsupported providers", async () => { |
| 216 | + const result = await handleLoginCommand(buildLoginParams("/login anthropic"), true); |
| 217 | + |
| 218 | + expect(result).toEqual({ |
| 219 | + shouldContinue: false, |
| 220 | + reply: { text: "Unsupported login provider. Use `/login codex`." }, |
| 221 | + }); |
| 222 | + expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); |
| 223 | + }); |
| 224 | +}); |
0 commit comments