Files
openclaw/src/commands/models/auth.test.ts
2026-03-07 18:51:17 +00:00

233 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
clackCancel: vi.fn(),
clackConfirm: vi.fn(),
clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")),
clackSelect: vi.fn(),
clackText: vi.fn(),
resolveDefaultAgentId: vi.fn(),
resolveAgentDir: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentWorkspaceDir: vi.fn(),
upsertAuthProfile: vi.fn(),
resolvePluginProviders: vi.fn(),
createClackPrompter: vi.fn(),
loginOpenAICodexOAuth: vi.fn(),
writeOAuthCredentials: vi.fn(),
loadValidConfigOrThrow: vi.fn(),
updateConfig: vi.fn(),
logConfigUpdated: vi.fn(),
openUrl: vi.fn(),
}));
vi.mock("@clack/prompts", () => ({
cancel: mocks.clackCancel,
confirm: mocks.clackConfirm,
isCancel: mocks.clackIsCancel,
select: mocks.clackSelect,
text: mocks.clackText,
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
resolveAgentDir: mocks.resolveAgentDir,
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
}));
vi.mock("../../agents/workspace.js", () => ({
resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir,
}));
vi.mock("../../agents/auth-profiles.js", () => ({
upsertAuthProfile: mocks.upsertAuthProfile,
}));
vi.mock("../../plugins/providers.js", () => ({
resolvePluginProviders: mocks.resolvePluginProviders,
}));
vi.mock("../../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
vi.mock("../openai-codex-oauth.js", () => ({
loginOpenAICodexOAuth: mocks.loginOpenAICodexOAuth,
}));
vi.mock("../onboard-auth.js", async (importActual) => {
const actual = await importActual<typeof import("../onboard-auth.js")>();
return {
...actual,
writeOAuthCredentials: mocks.writeOAuthCredentials,
};
});
vi.mock("./shared.js", async (importActual) => {
const actual = await importActual<typeof import("./shared.js")>();
return {
...actual,
loadValidConfigOrThrow: mocks.loadValidConfigOrThrow,
updateConfig: mocks.updateConfig,
};
});
vi.mock("../../config/logging.js", () => ({
logConfigUpdated: mocks.logConfigUpdated,
}));
vi.mock("../onboard-helpers.js", () => ({
openUrl: mocks.openUrl,
}));
const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand } = await import("./auth.js");
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function withInteractiveStdin() {
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,
});
return () => {
if (previousIsTTYDescriptor) {
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
} else if (!hadOwnIsTTY) {
delete (stdin as { isTTY?: boolean }).isTTY;
}
};
}
describe("modelsAuthLoginCommand", () => {
let restoreStdin: (() => void) | null = null;
let currentConfig: OpenClawConfig;
let lastUpdatedConfig: OpenClawConfig | null;
beforeEach(() => {
vi.clearAllMocks();
restoreStdin = withInteractiveStdin();
currentConfig = {};
lastUpdatedConfig = null;
mocks.clackCancel.mockReset();
mocks.clackConfirm.mockReset();
mocks.clackIsCancel.mockImplementation(
(value: unknown) => value === Symbol.for("clack:cancel"),
);
mocks.clackSelect.mockReset();
mocks.clackText.mockReset();
mocks.upsertAuthProfile.mockReset();
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace");
mocks.resolveDefaultAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace");
mocks.loadValidConfigOrThrow.mockImplementation(async () => currentConfig);
mocks.updateConfig.mockImplementation(
async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
lastUpdatedConfig = mutator(currentConfig);
currentConfig = lastUpdatedConfig;
return lastUpdatedConfig;
},
);
mocks.createClackPrompter.mockReturnValue({
note: vi.fn(async () => {}),
select: vi.fn(),
});
mocks.loginOpenAICodexOAuth.mockResolvedValue({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
email: "user@example.com",
});
mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com");
mocks.resolvePluginProviders.mockReturnValue([]);
});
afterEach(() => {
restoreStdin?.();
restoreStdin = null;
});
it("supports built-in openai-codex login without provider plugins", async () => {
const runtime = createRuntime();
await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime);
expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce();
expect(mocks.writeOAuthCredentials).toHaveBeenCalledWith(
"openai-codex",
expect.any(Object),
"/tmp/openclaw/agents/main",
{ syncSiblingAgents: true },
);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({
provider: "openai-codex",
mode: "oauth",
});
expect(runtime.log).toHaveBeenCalledWith(
"Auth profile: openai-codex:user@example.com (openai-codex/oauth)",
);
expect(runtime.log).toHaveBeenCalledWith(
"Default model available: openai-codex/gpt-5.3-codex (use --set-default to apply)",
);
});
it("applies openai-codex default model when --set-default is used", async () => {
const runtime = createRuntime();
await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime);
expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({
primary: "openai-codex/gpt-5.3-codex",
});
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.3-codex");
});
it("keeps existing plugin error behavior for non built-in providers", async () => {
const runtime = createRuntime();
await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow(
"No provider plugins found.",
);
});
it("does not persist a cancelled manual token entry", async () => {
const runtime = createRuntime();
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
code?: string | number | null,
) => {
throw new Error(`exit:${String(code ?? "")}`);
}) as typeof process.exit);
try {
const cancelSymbol = Symbol.for("clack:cancel");
mocks.clackText.mockResolvedValue(cancelSymbol);
mocks.clackIsCancel.mockImplementation((value: unknown) => value === cancelSymbol);
await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow(
"exit:0",
);
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
expect(mocks.updateConfig).not.toHaveBeenCalled();
expect(mocks.logConfigUpdated).not.toHaveBeenCalled();
} finally {
exitSpy.mockRestore();
}
});
});