mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:30:43 +00:00
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
|
|
import type { ClaudeCliCredential } from "./cli-credentials.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
readClaudeCliCredentialsCached: vi.fn<() => ClaudeCliCredential | null>(() => null),
|
|
readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
|
readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
|
}));
|
|
|
|
let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential;
|
|
let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
|
|
let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential;
|
|
let isSafeToUseExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").isSafeToUseExternalCliCredential;
|
|
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential;
|
|
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential;
|
|
let CLAUDE_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CLAUDE_CLI_PROFILE_ID;
|
|
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
|
|
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
|
|
|
|
function makeOAuthCredential(
|
|
overrides: Partial<OAuthCredential> & Pick<OAuthCredential, "provider">,
|
|
) {
|
|
return {
|
|
type: "oauth" as const,
|
|
provider: overrides.provider,
|
|
access: overrides.access ?? `${overrides.provider}-access`,
|
|
refresh: overrides.refresh ?? `${overrides.provider}-refresh`,
|
|
expires: overrides.expires ?? Date.now() + 10 * 60_000,
|
|
accountId: overrides.accountId,
|
|
email: overrides.email,
|
|
enterpriseUrl: overrides.enterpriseUrl,
|
|
projectId: overrides.projectId,
|
|
};
|
|
}
|
|
|
|
function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfileStore {
|
|
return {
|
|
version: 1,
|
|
profiles: profileId && credential ? { [profileId]: credential } : {},
|
|
};
|
|
}
|
|
|
|
describe("external cli oauth resolution", () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.doMock("./cli-credentials.js", () => ({
|
|
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
|
|
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
|
|
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
|
|
}));
|
|
mocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
|
|
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
|
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
|
({
|
|
hasUsableOAuthCredential,
|
|
isSafeToUseExternalCliCredential,
|
|
readManagedExternalCliCredential,
|
|
resolveExternalCliAuthProfiles,
|
|
shouldBootstrapFromExternalCliCredential,
|
|
shouldReplaceStoredOAuthCredential,
|
|
} = await import("./auth-profiles/external-cli-sync.js"));
|
|
({ CLAUDE_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
|
|
await import("./auth-profiles/constants.js"));
|
|
});
|
|
|
|
describe("shouldReplaceStoredOAuthCredential", () => {
|
|
it("keeps equivalent stored credentials", () => {
|
|
const expires = Date.now() + 60_000;
|
|
const stored = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "a",
|
|
refresh: "r",
|
|
expires,
|
|
});
|
|
const incoming = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "a",
|
|
refresh: "r",
|
|
expires,
|
|
});
|
|
|
|
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
|
|
});
|
|
|
|
it("keeps the newer stored credential", () => {
|
|
const incoming = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
expires: Date.now() + 60_000,
|
|
});
|
|
const stored = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "fresh-access",
|
|
refresh: "fresh-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
});
|
|
|
|
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
|
|
});
|
|
|
|
it("replaces when incoming credentials are fresher", () => {
|
|
const stored = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
expires: Date.now() + 60_000,
|
|
});
|
|
const incoming = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "new-access",
|
|
refresh: "new-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
});
|
|
|
|
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(true);
|
|
expect(shouldReplaceStoredOAuthCredential(undefined, incoming)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("external cli bootstrap policy", () => {
|
|
it("treats only non-expired and non-near-expiry access tokens as usable local oauth", () => {
|
|
expect(
|
|
hasUsableOAuthCredential(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "live-access",
|
|
expires: Date.now() + 10 * 60_000,
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
hasUsableOAuthCredential(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "expired-access",
|
|
expires: Date.now() - 60_000,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
hasUsableOAuthCredential(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "near-expiry-access",
|
|
expires: Date.now() + 60_000,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
hasUsableOAuthCredential(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "",
|
|
expires: Date.now() + 60_000,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("only bootstraps from external cli when the stored oauth is not usable", () => {
|
|
const imported = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "fresh-cli-access",
|
|
refresh: "fresh-cli-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
accountId: "acct-123",
|
|
});
|
|
|
|
expect(
|
|
shouldBootstrapFromExternalCliCredential({
|
|
existing: makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "healthy-local-access",
|
|
refresh: "healthy-local-refresh",
|
|
expires: Date.now() + 10 * 60_000,
|
|
}),
|
|
imported,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldBootstrapFromExternalCliCredential({
|
|
existing: makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "expired-local-access",
|
|
refresh: "expired-local-refresh",
|
|
expires: Date.now() - 60_000,
|
|
accountId: "acct-123",
|
|
}),
|
|
imported,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldBootstrapFromExternalCliCredential({
|
|
existing: makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "near-expiry-local-access",
|
|
refresh: "near-expiry-local-refresh",
|
|
expires: Date.now() + 60_000,
|
|
}),
|
|
imported,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("refuses external oauth usage across different known identities", () => {
|
|
const imported = makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "fresh-cli-access",
|
|
refresh: "fresh-cli-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
accountId: "acct-external",
|
|
});
|
|
|
|
expect(
|
|
isSafeToUseExternalCliCredential(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "expired-local-access",
|
|
refresh: "expired-local-refresh",
|
|
expires: Date.now() - 60_000,
|
|
accountId: "acct-local",
|
|
}),
|
|
imported,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("does not use codex as a runtime bootstrap source anymore", () => {
|
|
mocks.readCodexCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "codex-access-token",
|
|
refresh: "codex-refresh-token",
|
|
}),
|
|
);
|
|
|
|
const credential = readManagedExternalCliCredential({
|
|
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
|
credential: makeOAuthCredential({ provider: "openai-codex" }),
|
|
});
|
|
|
|
expect(credential).toBeNull();
|
|
});
|
|
|
|
it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => {
|
|
mocks.readCodexCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "codex-cli-access",
|
|
refresh: "codex-cli-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
accountId: "acct-codex",
|
|
}),
|
|
);
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(makeStore());
|
|
|
|
expect(profiles).toEqual([
|
|
{
|
|
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
|
credential: expect.objectContaining({
|
|
provider: "openai-codex",
|
|
access: "codex-cli-access",
|
|
refresh: "codex-cli-refresh",
|
|
accountId: "acct-codex",
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => {
|
|
mocks.readCodexCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "codex-cli-fresh-access",
|
|
refresh: "codex-cli-fresh-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
accountId: "acct-codex",
|
|
}),
|
|
);
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(
|
|
makeStore(
|
|
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
|
makeOAuthCredential({
|
|
provider: "openai-codex",
|
|
access: "local-expired-access",
|
|
refresh: "local-canonical-refresh",
|
|
expires: Date.now() - 5_000,
|
|
accountId: "acct-codex",
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(profiles).toEqual([]);
|
|
});
|
|
|
|
it("returns null when the profile id/provider do not map to the same external source", () => {
|
|
mocks.readCodexCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({ provider: "openai-codex" }),
|
|
);
|
|
|
|
const credential = readManagedExternalCliCredential({
|
|
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
|
credential: makeOAuthCredential({ provider: "anthropic" }),
|
|
});
|
|
|
|
expect(credential).toBeNull();
|
|
});
|
|
|
|
it("normalizes Claude CLI oauth credentials into the managed Claude profile", () => {
|
|
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
|
type: "oauth",
|
|
provider: "anthropic",
|
|
access: "claude-cli-access",
|
|
refresh: "claude-cli-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
});
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(makeStore());
|
|
|
|
expect(profiles).toEqual([
|
|
{
|
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
|
credential: expect.objectContaining({
|
|
type: "oauth",
|
|
provider: "claude-cli",
|
|
access: "claude-cli-access",
|
|
refresh: "claude-cli-refresh",
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("ignores Claude CLI token credentials", () => {
|
|
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
|
type: "token",
|
|
provider: "anthropic",
|
|
token: "claude-cli-token",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
});
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(makeStore());
|
|
|
|
expect(profiles).toEqual([]);
|
|
});
|
|
|
|
it("resolves fresher minimax external oauth profiles as runtime overlays", () => {
|
|
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "minimax-fresh-access",
|
|
refresh: "minimax-fresh-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
email: "minimax@example.com",
|
|
}),
|
|
);
|
|
|
|
const profiles = resolveExternalCliAuthProfiles({
|
|
version: 1,
|
|
profiles: {
|
|
[MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "minimax-stale-access",
|
|
refresh: "minimax-stale-refresh",
|
|
expires: Date.now() - 5_000,
|
|
email: "minimax@example.com",
|
|
}),
|
|
},
|
|
});
|
|
|
|
const profilesById = new Map(
|
|
profiles.map((profile) => [profile.profileId, profile.credential]),
|
|
);
|
|
expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({
|
|
access: "minimax-fresh-access",
|
|
refresh: "minimax-fresh-refresh",
|
|
});
|
|
});
|
|
|
|
it("does not emit runtime overlays when the stored minimax credential is newer", () => {
|
|
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "stale-external-access",
|
|
refresh: "stale-external-refresh",
|
|
expires: Date.now() - 5_000,
|
|
}),
|
|
);
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(
|
|
makeStore(
|
|
MINIMAX_CLI_PROFILE_ID,
|
|
makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "fresh-store-access",
|
|
refresh: "fresh-store-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(profiles).toEqual([]);
|
|
});
|
|
|
|
it("does not overlay fresh minimax oauth over a still-usable local credential", () => {
|
|
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
|
|
makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "fresh-cli-access",
|
|
refresh: "fresh-cli-refresh",
|
|
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
|
}),
|
|
);
|
|
|
|
const profiles = resolveExternalCliAuthProfiles(
|
|
makeStore(
|
|
MINIMAX_CLI_PROFILE_ID,
|
|
makeOAuthCredential({
|
|
provider: "minimax-portal",
|
|
access: "healthy-local-access",
|
|
refresh: "healthy-local-refresh",
|
|
expires: Date.now() + 10 * 60_000,
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(profiles).toEqual([]);
|
|
});
|
|
});
|