Plugins: add provider auth contracts
This commit is contained in:
parent
b3025e6d8e
commit
bbf3b4acf2
255
src/plugins/contracts/auth.contract.test.ts
Normal file
255
src/plugins/contracts/auth.contract.test.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "../../agents/auth-profiles/store.js";
|
||||
import { createNonExitingRuntime } from "../../runtime.js";
|
||||
import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js";
|
||||
import type { WizardPrompter, WizardProgress } from "../../wizard/prompts.js";
|
||||
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
|
||||
|
||||
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../commands/openai-codex-oauth.js", () => ({
|
||||
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
|
||||
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../providers/github-copilot-auth.js", () => ({
|
||||
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
|
||||
}));
|
||||
|
||||
const openAIPlugin = (await import("../../../extensions/openai/index.js")).default;
|
||||
const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default;
|
||||
const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default;
|
||||
|
||||
function buildPrompter(): WizardPrompter {
|
||||
const progress: WizardProgress = {
|
||||
update() {},
|
||||
stop() {},
|
||||
};
|
||||
return {
|
||||
intro: async () => {},
|
||||
outro: async () => {},
|
||||
note: async () => {},
|
||||
select: async () => "",
|
||||
multiselect: async () => [],
|
||||
text: async () => "",
|
||||
confirm: async () => false,
|
||||
progress: () => progress,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthContext() {
|
||||
return {
|
||||
config: {},
|
||||
prompter: buildPrompter(),
|
||||
runtime: createNonExitingRuntime(),
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: vi.fn() as never,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 contract", () => {
|
||||
afterEach(() => {
|
||||
loginOpenAICodexOAuthMock.mockReset();
|
||||
loginQwenPortalOAuthMock.mockReset();
|
||||
githubCopilotLoginCommandMock.mockReset();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
});
|
||||
|
||||
it("keeps OpenAI Codex OAuth auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
||||
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({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:user@example.com",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
email: "user@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai-codex/gpt-5.4": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: "openai-codex/gpt-5.4",
|
||||
notes: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => {
|
||||
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
||||
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
|
||||
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Qwen portal OAuth auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
|
||||
loginQwenPortalOAuthMock.mockResolvedValueOnce({
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
resourceUrl: "portal.qwen.ai",
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "qwen-portal:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "qwen-portal/coder-model",
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
"qwen-portal": {
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("auto-refresh"),
|
||||
expect.stringContaining("Base URL defaults"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot device auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{
|
||||
store: {
|
||||
version: 1,
|
||||
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 GitHub Copilot auth gated on interactive TTYs", async () => {
|
||||
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user