Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
import type { AuthInteraction } from "@earendil-works/pi-ai";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createManualAnthropicOAuthLogin,
|
|
MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES,
|
|
} from "./pi-anthropic-oauth.js";
|
|
|
|
describe("manual Anthropic OAuth", () => {
|
|
it("runs concurrent paste-back flows without binding a callback port", async () => {
|
|
const requests: RequestInit[] = [];
|
|
const fakeFetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
requests.push(init ?? {});
|
|
return Response.json({
|
|
access_token: `access-${requests.length}`,
|
|
refresh_token: `refresh-${requests.length}`,
|
|
expires_in: 3600,
|
|
});
|
|
}) as typeof fetch;
|
|
let verifier = 0;
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: fakeFetch,
|
|
createVerifier: () => `verifier-${++verifier}`,
|
|
});
|
|
|
|
const [first, second] = await Promise.all([
|
|
login(interactionFor("code-one#verifier-1")),
|
|
login(interactionFor("code-two#verifier-2")),
|
|
]);
|
|
|
|
expect(first.access).toBe("access-1");
|
|
expect(second.access).toBe("access-2");
|
|
expect(requests).toHaveLength(2);
|
|
expect(JSON.parse(String(requests[0]?.body))).toMatchObject({
|
|
code: "code-one",
|
|
state: "verifier-1",
|
|
code_verifier: "verifier-1",
|
|
redirect_uri: "http://localhost:53692/callback",
|
|
});
|
|
});
|
|
|
|
it("rejects a pasted callback with the wrong OAuth state before exchange", async () => {
|
|
let requested = false;
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: (async () => {
|
|
requested = true;
|
|
return Response.json({});
|
|
}) as typeof fetch,
|
|
createVerifier: () => "expected-state",
|
|
});
|
|
|
|
await expect(
|
|
login(interactionFor("http://localhost:53692/callback?code=x&state=wrong-state")),
|
|
).rejects.toThrow(/state mismatch/i);
|
|
expect(requested).toBe(false);
|
|
});
|
|
|
|
it("rejects and cancels a declared oversized token response", async () => {
|
|
const response = new Response("oversized", {
|
|
headers: { "content-length": String(MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES + 1) },
|
|
});
|
|
const cancel = vi.spyOn(response.body!, "cancel");
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: vi.fn().mockResolvedValue(response),
|
|
createVerifier: () => "expected-state",
|
|
});
|
|
|
|
await expect(login(interactionFor("code#expected-state"))).rejects.toThrow(
|
|
"Anthropic token exchange response is too large.",
|
|
);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not wait past cancellation when an oversized body cancel hangs", async () => {
|
|
let cancelStarted = false;
|
|
const hangingBody = new ReadableStream<Uint8Array>({
|
|
cancel() {
|
|
cancelStarted = true;
|
|
return new Promise(() => undefined);
|
|
},
|
|
});
|
|
const abort = new AbortController();
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: vi.fn(async () => {
|
|
setTimeout(() => abort.abort(), 20);
|
|
return new Response(hangingBody, {
|
|
headers: { "content-length": String(MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES + 1) },
|
|
});
|
|
}),
|
|
createVerifier: () => "expected-state",
|
|
});
|
|
|
|
const started = Date.now();
|
|
await expect(login(interactionFor("code#expected-state", abort.signal))).rejects.toThrow(
|
|
"Anthropic token exchange response is too large.",
|
|
);
|
|
expect(cancelStarted).toBe(true);
|
|
expect(Date.now() - started).toBeLessThan(500);
|
|
});
|
|
|
|
it("caps a streamed token response without a content length", async () => {
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: vi
|
|
.fn()
|
|
.mockResolvedValue(new Response(new Uint8Array(MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES + 1))),
|
|
createVerifier: () => "expected-state",
|
|
});
|
|
|
|
await expect(login(interactionFor("code#expected-state"))).rejects.toThrow(
|
|
"Anthropic token exchange response is too large.",
|
|
);
|
|
});
|
|
|
|
it("uses the request deadline while reading the token response", async () => {
|
|
const requestSignals: AbortSignal[] = [];
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("{"));
|
|
},
|
|
}),
|
|
);
|
|
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
requestSignals.push(init?.signal as AbortSignal);
|
|
return response;
|
|
});
|
|
const login = createManualAnthropicOAuthLogin({
|
|
fetch: fetch as typeof globalThis.fetch,
|
|
createVerifier: () => "expected-state",
|
|
});
|
|
const controller = new AbortController();
|
|
const result = login(interactionFor("code#expected-state", controller.signal));
|
|
controller.abort(new Error("login cancelled"));
|
|
|
|
await expect(result).rejects.toThrow("login cancelled");
|
|
expect(requestSignals[0]?.aborted).toBe(true);
|
|
});
|
|
});
|
|
|
|
function interactionFor(input: string, signal = new AbortController().signal): AuthInteraction {
|
|
return {
|
|
signal,
|
|
async prompt() {
|
|
return input;
|
|
},
|
|
notify(event) {
|
|
if (event.type === "auth_url") {
|
|
expect(event.url).toMatch(/^https:\/\/claude\.ai\/oauth\/authorize\?/);
|
|
}
|
|
},
|
|
};
|
|
}
|