Files
pi_harness/packages/ai/test/anthropic-oauth.test.ts
T
Mario Zechner 8a0903ebf2 feat(ai): compat entrypoint, core-only root barrel (phase 5)
The root barrel is now core-only and side-effect free: types,
createModels/createProvider, auth substrate, lazyStream/lazyApi, faux,
utils. Generated catalogs, api-registry, env-api-keys, images, global
stream functions, and per-API lazy wrappers leave the root.

New @earendil-works/pi-ai/compat preserves the old surface verbatim as
a strict superset of the root: api-dispatch stream/complete with env
key injection, the builtin registration side effect (skip-if-present so
it cannot clobber earlier overrides), deprecated getModel/getModels/
getProviders aliases of the new getBuiltin* reads in providers/all,
lazy api wrappers + setBedrockProviderModule, and image generation.
Compat dies with the coding-agent ModelManager migration.

Packaging: exports map gains ./compat, ./providers/*, ./api/*;
sideEffects array lists only the effectful modules.

Old-global imports across agent/coding-agent/examples and pi-ai tests
switch to /compat (path-only; compat is a superset). The coding-agent
extension loader resolves the pi-ai ROOT specifier to compat, so
existing user extensions using the old global API keep working at
runtime until compat is removed. vitest configs alias /compat to src;
browser smoke imports old globals from /compat.
2026-06-10 21:17:12 +02:00

135 lines
4.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.ts";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
},
});
}
function getUrl(input: unknown): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (input instanceof Request) {
return input.url;
}
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function getJsonBody(init?: RequestInit): Record<string, string> {
if (typeof init?.body !== "string") {
throw new Error(`Expected string request body, got ${typeof init?.body}`);
}
return JSON.parse(init.body) as Record<string, string>;
}
describe.sequential("Anthropic OAuth", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the localhost redirect_uri for manual callback login", async () => {
let authUrl = "";
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
expect(init?.method).toBe("POST");
const body = getJsonBody(init);
expect(body.grant_type).toBe("authorization_code");
expect(body.code).toBe("manual-code");
expect(body.redirect_uri).toBe("http://localhost:53692/callback");
return jsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await loginAnthropic({
onAuth: (info) => {
authUrl = info.url;
},
onPrompt: async () => "",
onManualCodeInput: async () => {
const url = new URL(authUrl);
const state = url.searchParams.get("state");
const redirectUri = url.searchParams.get("redirect_uri");
if (!state || !redirectUri) {
throw new Error("Missing OAuth state or redirect_uri in auth URL");
}
return `${redirectUri}?code=manual-code&state=${state}`;
},
});
expect(credentials.access).toBe("access-token");
expect(credentials.refresh).toBe("refresh-token");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("omits scope from refresh token requests", async () => {
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
expect(init?.method).toBe("POST");
const body = getJsonBody(init);
expect(body.grant_type).toBe("refresh_token");
expect(body.client_id).toBeTruthy();
expect(body.refresh_token).toBe("refresh-token");
expect(body).not.toHaveProperty("scope");
return jsonResponse({
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await refreshAnthropicToken("refresh-token");
expect(credentials.access).toBe("new-access-token");
expect(credentials.refresh).toBe("new-refresh-token");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("anthropicOAuth.login resolves through the manual_code prompt and aborts it after settling", async () => {
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/oauth/token")) {
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const events: AuthEvent[] = [];
const prompts: AuthPrompt[] = [];
let manualSignal: AbortSignal | undefined;
const credential = await anthropicOAuth.login({
notify: (event) => events.push(event),
prompt: async (prompt) => {
prompts.push(prompt);
if (prompt.type === "manual_code") {
manualSignal = prompt.signal;
return "the-code";
}
throw new Error(`Unexpected prompt: ${prompt.type}`);
},
});
expect(credential.type).toBe("oauth");
expect(credential.access).toBe("access");
expect(events.some((e) => e.type === "auth_url")).toBe(true);
expect(prompts.some((p) => p.type === "manual_code")).toBe(true);
// the prompt's signal is aborted once login settles, so UIs can dismiss it
expect(manualSignal?.aborted).toBe(true);
});
});