// Provider auth tests cover credential resolution, setup state, and auth method contracts. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; type FallbackStoreCaseResult = { profileIds: string[]; resolvedKey: string | undefined; resolveApiKeyCalls: unknown[][]; }; async function runFallbackStoreCase(): Promise { vi.resetModules(); const primaryStore: AuthProfileStore = { version: 1, profiles: {}, }; const fallbackStore: AuthProfileStore = { version: 1, profiles: { "openai:default": { type: "api_key", provider: "openai", key: "fallback-key", }, }, }; const resolveApiKeyForProfile = vi.fn( async (params: { store: AuthProfileStore; profileId: string }) => { const profile = params.store.profiles[params.profileId]; return profile?.type === "api_key" && profile.key ? { apiKey: profile.key, provider: profile.provider, profileId: params.profileId, profileType: profile.type, } : null; }, ); vi.doMock("../agents/agent-scope-config.js", () => ({ resolveDefaultAgentDir: () => "/tmp/openclaw-agent", })); vi.doMock("../agents/auth-profiles/oauth.js", () => ({ resolveApiKeyForProfile, })); vi.doMock("../agents/auth-profiles/order.js", () => ({ resolveAuthProfileOrder: ({ provider, store }: { provider: string; store: AuthProfileStore }) => Object.entries(store.profiles) .filter(([, profile]) => profile.provider === provider) .map(([profileId]) => profileId), })); vi.doMock("../agents/auth-profiles/store.js", () => ({ ensureAuthProfileStore: vi.fn(() => primaryStore), ensureAuthProfileStoreForLocalUpdate: vi.fn(() => primaryStore), loadAuthProfileStoreForSecretsRuntime: vi.fn(() => primaryStore), loadAuthProfileStoreWithoutExternalProfiles: vi.fn(() => fallbackStore), updateAuthProfileStoreWithLock: vi.fn(), })); const { listUsableProviderAuthProfileIds, resolveProviderAuthProfileApiKey } = await import("./provider-auth.js"); return { profileIds: listUsableProviderAuthProfileIds({ provider: "openai" }).profileIds, resolvedKey: await resolveProviderAuthProfileApiKey({ provider: "openai" }), resolveApiKeyCalls: resolveApiKeyForProfile.mock.calls, }; } describe("provider auth profile helpers", () => { let fallbackStoreCase: FallbackStoreCaseResult; beforeAll(async () => { fallbackStoreCase = await runFallbackStoreCase(); }); afterEach(() => { vi.doUnmock("../agents/agent-scope-config.js"); vi.doUnmock("../agents/auth-profiles/external-cli-discovery.js"); vi.doUnmock("../agents/auth-profiles/oauth.js"); vi.doUnmock("../agents/auth-profiles/order.js"); vi.doUnmock("../agents/auth-profiles/store.js"); vi.resetModules(); }); it("resolves API keys from the fallback store that supplied usable profile ids", () => { expect(fallbackStoreCase.profileIds).toEqual(["openai:default"]); expect(fallbackStoreCase.resolvedKey).toBe("fallback-key"); expect(fallbackStoreCase.resolveApiKeyCalls).toContainEqual([ expect.objectContaining({ agentDir: "/tmp/openclaw-agent", profileId: "openai:default", store: expect.objectContaining({ profiles: expect.objectContaining({ "openai:default": expect.objectContaining({ key: "fallback-key" }), }), }), }), ]); }); it("filters auth profile API-key resolution by credential type", async () => { vi.resetModules(); const store: AuthProfileStore = { version: 1, profiles: { "openai:oauth": { type: "oauth", provider: "openai", access: "oauth-access", refresh: "oauth-refresh", expires: Date.now() + 60_000, }, "openai:key": { type: "api_key", provider: "openai", key: "sk-profile", }, }, }; const resolveApiKeyForProfile = vi.fn( async (params: { store: AuthProfileStore; profileId: string }) => { const profile = params.store.profiles[params.profileId]; if (profile?.type === "oauth") { return { apiKey: profile.access, provider: profile.provider, profileId: params.profileId, profileType: profile.type, }; } if (profile?.type === "api_key" && profile.key) { return { apiKey: profile.key, provider: profile.provider, profileId: params.profileId, profileType: profile.type, }; } return null; }, ); vi.doMock("../agents/agent-scope-config.js", () => ({ resolveDefaultAgentDir: () => "/tmp/openclaw-agent", })); vi.doMock("../agents/auth-profiles/oauth.js", () => ({ resolveApiKeyForProfile, })); vi.doMock("../agents/auth-profiles/order.js", () => ({ resolveAuthProfileOrder: ({ provider, store: profileStore, }: { provider: string; store: AuthProfileStore; }) => Object.entries(profileStore.profiles) .filter(([, profile]) => profile.provider === provider) .map(([profileId]) => profileId), })); vi.doMock("../agents/auth-profiles/store.js", () => ({ ensureAuthProfileStore: vi.fn(() => store), ensureAuthProfileStoreForLocalUpdate: vi.fn(() => store), loadAuthProfileStoreForSecretsRuntime: vi.fn(() => store), loadAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ version: 1, profiles: {} })), updateAuthProfileStoreWithLock: vi.fn(), })); const { resolveProviderAuthProfileApiKey } = await import("./provider-auth.js"); await expect( resolveProviderAuthProfileApiKey({ provider: "openai", profileTypes: ["api_key"], }), ).resolves.toBe("sk-profile"); expect(resolveApiKeyForProfile).toHaveBeenCalledTimes(1); expect(resolveApiKeyForProfile).toHaveBeenCalledWith( expect.objectContaining({ profileId: "openai:key" }), ); }); it("only discovers external CLI auth when provider resolution opts in", async () => { vi.resetModules(); const primaryStore: AuthProfileStore = { version: 1, profiles: {}, }; const externalStore: AuthProfileStore = { version: 1, profiles: { "openai:default": { type: "oauth", provider: "openai", access: "oauth-access", refresh: "oauth-refresh", expires: Date.now() + 60_000, }, }, }; const externalCli = { mode: "scoped", providerIds: ["openai"] }; const loadAuthProfileStoreForSecretsRuntime = vi.fn( (_agentDir?: string, options?: { externalCli?: unknown }) => options?.externalCli ? externalStore : primaryStore, ); vi.doMock("../agents/agent-scope-config.js", () => ({ resolveDefaultAgentDir: () => "/tmp/openclaw-agent", })); vi.doMock("../agents/auth-profiles/external-cli-discovery.js", () => ({ externalCliDiscoveryForProviderAuth: vi.fn(() => externalCli), })); vi.doMock("../agents/auth-profiles/oauth.js", () => ({ resolveApiKeyForProfile: vi.fn(), })); vi.doMock("../agents/auth-profiles/order.js", () => ({ resolveAuthProfileOrder: ({ provider, store, }: { provider: string; store: AuthProfileStore; }) => Object.entries(store.profiles) .filter(([, profile]) => profile.provider === provider) .map(([profileId]) => profileId), })); vi.doMock("../agents/auth-profiles/store.js", () => ({ ensureAuthProfileStore: vi.fn(() => primaryStore), ensureAuthProfileStoreForLocalUpdate: vi.fn(() => primaryStore), loadAuthProfileStoreForSecretsRuntime, loadAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ version: 1, profiles: {} })), updateAuthProfileStoreWithLock: vi.fn(), })); const { isProviderAuthProfileConfigured } = await import("./provider-auth.js"); expect(isProviderAuthProfileConfigured({ provider: "openai" })).toBe(false); expect( isProviderAuthProfileConfigured({ provider: "openai", includeExternalCliAuth: true, }), ).toBe(true); expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith(1, "/tmp/openclaw-agent"); expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith( 2, "/tmp/openclaw-agent", { externalCli }, ); }); it("accepts plus-signed Copilot token expiry strings", async () => { vi.resetModules(); const saved: unknown[] = []; const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ token: "token;proxy-ep=proxy.individual.githubcopilot.com", expires_at: "+2000000000", }), { status: 200, headers: { "content-type": "application/json" } }, ), ); const { resolveCopilotApiToken } = await import("./provider-auth.js"); const result = await resolveCopilotApiToken({ githubToken: "github-token", fetchImpl, cachePath: "/tmp/copilot-token.json", loadJsonFileImpl: () => undefined, saveJsonFileImpl: (_path, value) => saved.push(value), }); expect(result.expiresAt).toBe(2_000_000_000_000); expect(saved).toEqual([ expect.objectContaining({ expiresAt: 2_000_000_000_000, token: "token;proxy-ep=proxy.individual.githubcopilot.com", }), ]); const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; expect(init.headers).toEqual( expect.objectContaining({ Accept: "application/json", Authorization: "Bearer github-token", "Copilot-Integration-Id": "vscode-chat", }), ); }); it("rejects malformed Copilot proxy hints", async () => { vi.resetModules(); const { deriveCopilotApiBaseUrlFromToken } = await import("./provider-auth.js"); expect( deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=javascript:alert(1);"), ).toBeNull(); expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=://bad;")).toBeNull(); }); it("rejects Copilot token expiry values outside the supported date range", async () => { vi.resetModules(); const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ token: "token;proxy-ep=proxy.individual.githubcopilot.com", expires_at: Number.MAX_SAFE_INTEGER, }), { status: 200, headers: { "content-type": "application/json" } }, ), ); const { resolveCopilotApiToken } = await import("./provider-auth.js"); await expect( resolveCopilotApiToken({ githubToken: "github-token", fetchImpl, cachePath: "/tmp/copilot-token.json", loadJsonFileImpl: () => undefined, saveJsonFileImpl: () => { throw new Error("should not save invalid token"); }, }), ).rejects.toThrow("Copilot token response has invalid expires_at"); }); it("cancels Copilot token exchange error bodies", async () => { vi.resetModules(); const response = new Response("bad credentials", { status: 401 }); const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined); const fetchImpl = vi.fn(async () => response); const { resolveCopilotApiToken } = await import("./provider-auth.js"); await expect( resolveCopilotApiToken({ githubToken: "github-token", fetchImpl, cachePath: "/tmp/copilot-token.json", loadJsonFileImpl: () => undefined, saveJsonFileImpl: () => { throw new Error("should not save failed token"); }, }), ).rejects.toThrow("Copilot token exchange failed: HTTP 401"); expect(cancel).toHaveBeenCalledOnce(); }); it("refreshes cached Copilot tokens with out-of-range expiry values", async () => { vi.resetModules(); const saved: unknown[] = []; const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ token: "fresh;proxy-ep=proxy.individual.githubcopilot.com", expires_at: "+2000000000", }), { status: 200, headers: { "content-type": "application/json" } }, ), ); const { COPILOT_INTEGRATION_ID, resolveCopilotApiToken } = await import("./provider-auth.js"); const result = await resolveCopilotApiToken({ githubToken: "github-token", fetchImpl, cachePath: "/tmp/copilot-token.json", loadJsonFileImpl: () => ({ token: "cached;proxy-ep=proxy.individual.githubcopilot.com", expiresAt: Number.MAX_SAFE_INTEGER, updatedAt: Date.now(), integrationId: COPILOT_INTEGRATION_ID, }), saveJsonFileImpl: (_path, value) => saved.push(value), }); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(result.source).toBe("fetched:https://api.github.com/copilot_internal/v2/token"); expect(result.token).toBe("fresh;proxy-ep=proxy.individual.githubcopilot.com"); expect(saved).toEqual([ expect.objectContaining({ expiresAt: 2_000_000_000_000, token: "fresh;proxy-ep=proxy.individual.githubcopilot.com", }), ]); }); });