Files
openclaw/test/helpers/plugins/provider-auth-contract.ts
2026-03-29 09:10:38 +01:00

390 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
import { registerProviders, requireProvider } from "../../../src/plugins/contracts/testkit.js";
import { createNonExitingRuntime } from "../../../src/runtime.js";
import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import type {
WizardMultiSelectParams,
WizardPrompter,
WizardProgress,
WizardSelectParams,
} from "../../../src/wizard/prompts.js";
type LoginOpenAICodexOAuth =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
type GithubCopilotLoginCommand =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
(typeof import("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
type EnsureAuthProfileStore =
typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore;
type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/provider-auth").listProfilesForProvider;
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-login")>();
return {
...actual,
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
const { default: githubCopilotPlugin } = loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({
pluginId: "github-copilot",
artifactBasename: "index.js",
});
const { default: openAIPlugin } = loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({
pluginId: "openai",
artifactBasename: "index.js",
});
function buildPrompter(): WizardPrompter {
const progress: WizardProgress = {
update() {},
stop() {},
};
return {
intro: async () => {},
outro: async () => {},
note: async () => {},
select: async <T>(params: WizardSelectParams<T>) => {
const option = params.options[0];
if (!option) {
throw new Error("missing select option");
}
return option.value;
},
multiselect: async <T>(params: WizardMultiSelectParams<T>) => params.initialValues ?? [],
text: async () => "",
confirm: async () => false,
progress: () => progress,
};
}
function buildAuthContext() {
return {
config: {},
prompter: buildPrompter(),
runtime: createNonExitingRuntime(),
isRemote: false,
openUrl: async () => {},
oauth: {
createVpsAwareHandlers: vi.fn<CreateVpsAwareHandlers>(),
},
};
}
function createJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
function buildOpenAICodexOAuthResult(params: {
profileId: string;
access: string;
refresh: string;
expires: number;
email?: string;
}) {
return {
profiles: [
{
profileId: params.profileId,
credential: {
type: "oauth" as const,
provider: "openai-codex",
access: params.access,
refresh: params.refresh,
expires: params.expires,
...(params.email ? { email: params.email } : {}),
},
},
],
configPatch: {
agents: {
defaults: {
models: {
"openai-codex/gpt-5.4": {},
},
},
},
},
defaultModel: "openai-codex/gpt-5.4",
notes: undefined,
};
}
function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) {
beforeEach(() => {
state.authStore = { version: 1, profiles: {} };
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockImplementation(() => state.authStore);
listProfilesForProviderMock.mockReset();
listProfilesForProviderMock.mockImplementation((store, providerId) =>
Object.entries(store.profiles)
.filter(([, credential]) => credential?.provider === providerId)
.map(([profileId]) => profileId),
);
});
afterEach(() => {
loginOpenAICodexOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
clearRuntimeAuthProfileStoreSnapshots();
});
}
export function describeOpenAICodexProviderAuthContract() {
const state = {
authStore: { version: 1, profiles: {} } as AuthProfileStore,
};
describe("openai-codex provider auth contract", () => {
installSharedAuthProfileStoreHooks(state);
async function expectStableFallbackProfile(params: { access: string; profileId: string }) {
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access: params.access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: params.profileId,
access: params.access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
}),
);
}
function getProvider() {
return requireProvider(registerProviders(openAIPlugin), "openai-codex");
}
it("keeps OAuth auth results provider-owned", async () => {
const provider = getProvider();
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
email: "user@example.com",
refresh: "refresh-token",
access: "access-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:user@example.com",
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "user@example.com",
}),
);
});
it("backfills OAuth email from the JWT profile claim", async () => {
const provider = getProvider();
const access = createJwt({
"https://api.openai.com/profile": {
email: "jwt-user@example.com",
},
});
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access,
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:jwt-user@example.com",
access,
refresh: "refresh-token",
expires: 1_700_000_000_000,
email: "jwt-user@example.com",
}),
);
});
it("uses a stable fallback id when JWT email is missing", async () => {
const access = createJwt({
"https://api.openai.com/auth": {
chatgpt_account_user_id: "user-123__acct-456",
},
});
const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url");
await expectStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
const access = createJwt({
iss: "https://accounts.openai.com",
sub: "user-abc",
});
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
"base64url",
);
await expectStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
const access = createJwt({
sub: "user-abc",
});
const expectedStableId = Buffer.from("user-abc").toString("base64url");
await expectStableFallbackProfile({
access,
profileId: `openai-codex:id-${expectedStableId}`,
});
});
it("falls back to the default profile when JWT parsing yields no identity", async () => {
const provider = getProvider();
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
refresh: "refresh-token",
access: "not-a-jwt-token",
expires: 1_700_000_000_000,
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toEqual(
buildOpenAICodexOAuthResult({
profileId: "openai-codex:default",
access: "not-a-jwt-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
}),
);
});
it("keeps OAuth failures non-fatal at the provider layer", async () => {
const provider = getProvider();
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
});
});
}
export function describeGithubCopilotProviderAuthContract() {
const state = {
authStore: { version: 1, profiles: {} } as AuthProfileStore,
};
describe("github-copilot provider auth contract", () => {
installSharedAuthProfileStoreHooks(state);
function getProvider() {
return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
}
it("keeps device auth results provider-owned", async () => {
const provider = getProvider();
state.authStore.profiles["github-copilot:github"] = {
type: "token",
provider: "github-copilot",
token: "github-device-token",
};
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => true,
});
try {
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith(
{ yes: true, profileId: "github-copilot:github" },
expect.any(Object),
);
expect(result).toEqual({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "github-device-token",
},
},
],
defaultModel: "github-copilot/gpt-4o",
});
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
it("keeps auth gated on interactive TTYs", async () => {
const provider = getProvider();
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
Object.defineProperty(stdin, "isTTY", {
configurable: true,
enumerable: true,
get: () => false,
});
try {
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
profiles: [],
});
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
} finally {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
}
});
});
}