diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts new file mode 100644 index 00000000000..6ffa2a22055 --- /dev/null +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; +import { applyAuthChoiceLoadedPluginProvider } from "../../commands/auth-choice.apply.plugin-provider.js"; +import { resolvePreferredProviderForAuthChoice } from "../../commands/auth-choice.preferred-provider.js"; +import type { AuthChoice } from "../../commands/onboard-types.js"; +import { + createAuthTestLifecycle, + createExitThrowingRuntime, + createWizardPrompter, + readAuthProfilesForAgent, + requireOpenClawAgentDir, + setupAuthTestEnv, +} from "../../commands/test-wizard-helpers.js"; +import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; +import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; + +const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); +const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); +const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); +const resolveProviderPluginChoiceMock = vi.hoisted(() => + vi.fn<() => { provider: ProviderPlugin; method: ProviderPlugin["auth"][number] } | null>(), +); +const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ + loginQwenPortalOAuth: loginQwenPortalOAuthMock, +})); + +vi.mock("../../providers/github-copilot-auth.js", () => ({ + githubCopilotLoginCommand: githubCopilotLoginCommandMock, +})); + +vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), + resolveProviderPluginChoice: (...args: unknown[]) => resolveProviderPluginChoiceMock(...args), + runProviderModelSelectedHook: (...args: unknown[]) => runProviderModelSelectedHookMock(...args), +})); + +type StoredAuthProfile = { + type?: string; + provider?: string; + access?: string; + refresh?: string; + key?: string; + token?: string; +}; + +const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; + +function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { + const captured = createCapturedPluginRegistration(); + for (const plugin of plugins) { + plugin.register(captured.api); + } + return captured.providers; +} + +function requireProvider(providers: ProviderPlugin[], providerId: string) { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + return provider; +} + +describe("provider auth-choice contract", () => { + const lifecycle = createAuthTestLifecycle([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); + let activeStateDir: string | null = null; + + async function setupTempState() { + if (activeStateDir) { + await lifecycle.cleanup(); + } + const env = await setupAuthTestEnv("openclaw-provider-auth-choice-"); + activeStateDir = env.stateDir; + lifecycle.setStateDir(env.stateDir); + } + + afterEach(async () => { + loginQwenPortalOAuthMock.mockReset(); + githubCopilotLoginCommandMock.mockReset(); + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue([]); + resolveProviderPluginChoiceMock.mockReset(); + resolveProviderPluginChoiceMock.mockReturnValue(null); + runProviderModelSelectedHookMock.mockReset(); + clearRuntimeAuthProfileStoreSnapshots(); + await lifecycle.cleanup(); + activeStateDir = null; + }); + + it("maps plugin-backed auth choices through the shared preferred-provider resolver", async () => { + const scenarios = [ + { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, + { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, + { authChoice: "minimax-global-oauth" as const, expectedProvider: "minimax-portal" }, + { authChoice: "modelstudio-api-key" as const, expectedProvider: "modelstudio" }, + { authChoice: "ollama" as const, expectedProvider: "ollama" }, + { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, + ] as const; + + for (const scenario of scenarios) { + await expect( + resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }), + ).resolves.toBe(scenario.expectedProvider); + } + }); + + it("applies qwen portal auth choices through the shared plugin-provider path", async () => { + await setupTempState(); + const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); + resolvePluginProvidersMock.mockReturnValue([qwenProvider]); + resolveProviderPluginChoiceMock.mockReturnValue({ + provider: qwenProvider, + method: qwenProvider.auth[0], + }); + loginQwenPortalOAuthMock.mockResolvedValueOnce({ + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + resourceUrl: "portal.qwen.ai", + }); + + const note = vi.fn(async () => {}); + const result = await applyAuthChoiceLoadedPluginProvider({ + authChoice: "qwen-portal", + config: {}, + prompter: createWizardPrompter({ note }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: "qwen-portal/coder-model", + }); + expect(result?.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ + provider: "qwen-portal", + mode: "oauth", + }); + expect(result?.config.models?.providers?.["qwen-portal"]).toMatchObject({ + baseUrl: "https://portal.qwen.ai/v1", + models: [], + }); + expect(note).toHaveBeenCalledWith( + "Default model set to qwen-portal/coder-model", + "Model configured", + ); + + const stored = await readAuthProfilesForAgent<{ profiles?: Record }>( + requireOpenClawAgentDir(), + ); + expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({ + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + }); + }); + + it("returns provider agent overrides when default-model application is deferred", async () => { + await setupTempState(); + const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); + resolvePluginProvidersMock.mockReturnValue([qwenProvider]); + resolveProviderPluginChoiceMock.mockReturnValue({ + provider: qwenProvider, + method: qwenProvider.auth[0], + }); + loginQwenPortalOAuthMock.mockResolvedValueOnce({ + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + resourceUrl: "portal.qwen.ai", + }); + + const result = await applyAuthChoiceLoadedPluginProvider({ + authChoice: "qwen-portal", + config: {}, + prompter: createWizardPrompter({}), + runtime: createExitThrowingRuntime(), + setDefaultModel: false, + }); + + expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + config: { + agents: { + defaults: { + models: { + "qwen-portal/coder-model": { + alias: "qwen", + }, + "qwen-portal/vision-model": {}, + }, + }, + }, + auth: { + profiles: { + "qwen-portal:default": { + provider: "qwen-portal", + mode: "oauth", + }, + }, + }, + models: { + providers: { + "qwen-portal": { + baseUrl: "https://portal.qwen.ai/v1", + models: [], + }, + }, + }, + }, + agentModelOverride: "qwen-portal/coder-model", + }); + + const stored = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(requireOpenClawAgentDir()); + expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({ + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + }); + }); +});