Heimdall/tests/provider-token-request.test.ts
Meta Vens 0dbcd98019 Collapse ten per-provider token functions into one descriptor-driven request
Endpoint, client-auth method (RFC 7591 names) and rotation now live on
ProviderDescriptor; requestProviderTokens is the only code that talks to a
token endpoint. Failure labels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:06:32 +02:00

116 lines
5.1 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { type HeimdallConfig } from "../src/config.js";
import { providers, type Provider } from "../src/contracts.js";
import { requestProviderTokens } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
function configFor(): HeimdallConfig {
return {
providers: Object.fromEntries(providers.map((p) => [p, { clientId: `${p}-client`, clientSecret: `${p}-secret` }])),
} as unknown as HeimdallConfig;
}
interface Captured {
url: string;
headers: Record<string, string>;
body: URLSearchParams;
}
function capture(answer: () => Response): { calls: Captured[] } {
const calls: Captured[] = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
calls.push({
url: String(input),
headers: init?.headers as Record<string, string>,
body: init?.body as URLSearchParams,
});
return answer();
}) as typeof fetch;
return { calls };
}
const ok = () =>
new Response(JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 3600, scope: "x y" }), {
status: 200,
});
const requestable: Provider[] = [...providers];
describe("requestProviderTokens follows the provider descriptor", () => {
it.each(requestable.flatMap((p) => [[p, "authorization_code"] as const, [p, "refresh_token"] as const]))(
"%s %s hits its token endpoint with its client-auth method only",
async (provider, grantType) => {
const { calls } = capture(ok);
const descriptor = providerCatalog[provider];
const grant =
grantType === "authorization_code"
? ({ grant_type: "authorization_code", code: "the-code", redirect_uri: "https://h/cb" } as const)
: ({ grant_type: "refresh_token", refresh_token: "the-refresh" } as const);
const tokens = await requestProviderTokens(provider, configFor(), grant);
expect(calls).toHaveLength(1);
const call = calls[0]!;
expect(call.url).toBe(descriptor.tokenEndpoint);
expect(call.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
expect(call.headers.Accept).toBe("application/json");
expect(call.body.get("grant_type")).toBe(grantType);
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
expect(call.headers.Authorization).toBe(`Basic ${Buffer.from(`${provider}-client:${provider}-secret`).toString("base64")}`);
expect(call.body.has("client_id")).toBe(false);
expect(call.body.has("client_secret")).toBe(false);
} else {
expect(call.headers.Authorization).toBeUndefined();
expect(call.body.get("client_id")).toBe(`${provider}-client`);
expect(call.body.get("client_secret")).toBe(`${provider}-secret`);
}
expect(tokens.accessToken).toBe("a");
expect(tokens.refreshToken).toBe("r");
expect(tokens.scope).toEqual(["x", "y"]);
expect(Date.parse(tokens.expiresAt ?? "")).toBeGreaterThan(Date.now());
}
);
it("sends the code and redirect uri on exchange and the refresh token on refresh", async () => {
const { calls } = capture(ok);
await requestProviderTokens("spotify", configFor(), { grant_type: "authorization_code", code: "C", redirect_uri: "https://h/cb" });
await requestProviderTokens("spotify", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
expect(calls[0]!.body.get("code")).toBe("C");
expect(calls[0]!.body.get("redirect_uri")).toBe("https://h/cb");
expect(calls[1]!.body.get("refresh_token")).toBe("R");
expect(calls[1]!.body.has("code")).toBe(false);
});
it.each([
["no expires_in", { access_token: "a" }],
["a zero expires_in", { access_token: "a", expires_in: 0 }],
["a string expires_in", { access_token: "a", expires_in: "3600" }],
])("sets no expiry and no refresh token for %s", async (_n, body) => {
capture(() => new Response(JSON.stringify(body), { status: 200 }));
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
expect(tokens.expiresAt).toBeUndefined();
expect(tokens.refreshToken).toBeUndefined();
});
it.each([
["authorization_code", { grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const, "exchange"],
["refresh_token", { grant_type: "refresh_token", refresh_token: "r" } as const, "refresh"],
])("keeps the failure label for %s", async (_g, grant, word) => {
capture(() => new Response("nope", { status: 500 }));
await expect(requestProviderTokens("youtube", configFor(), grant)).rejects.toThrow(
`YouTube token ${word} failed (status 500, provider_status).`
);
});
it("refuses an unconfigured provider by display name", async () => {
const config = { providers: { patreon: {} } } as unknown as HeimdallConfig;
await expect(
requestProviderTokens("patreon", config, { grant_type: "refresh_token", refresh_token: "r" })
).rejects.toThrow("Patreon OAuth is not fully configured.");
});
});