mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(media-use): use CLI free HeyGen usage * fix(media-use): address #2027 R1 nits — gate cli-source header to OAuth, export origin constant - X-HeyGen-Source is now sent only on OAuth (Bearer) requests, not API-key ones — the backend ignores it for API-key traffic (normal billing), so it was dead metadata there. buildAuthHeaders + heygenAuthHeaders + tests updated. - Export HEYGEN_CLI_ORIGIN_HEADER ("X-HeyGen-Client-Origin") for future cli:<origin> consumers. - Document the deliberate paid/X4 confirm-before-call decision on heygen.tts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * refactor(cli): drop unused origin-header export, dedup auth-client tests Fallow flagged 5 findings on this PR: - major: HEYGEN_CLI_ORIGIN_HEADER was exported but never emitted or imported — speculative dead code ("future consumers"). Remove it; a real consumer can add the constant when one exists. - 4x minor duplication in client.test.ts: fold the repeated `.rejects.toSatisfy(auth-code)` assertion into expectAuthCode(), and the repeated try/catch scrubbed-message assertion into expectRejectionMessage(). No behavior change; auth/client tests still 17/17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
AuthClient,
|
|
HEYGEN_CLI_SOURCE,
|
|
HEYGEN_CLI_SOURCE_HEADER,
|
|
apiBaseUrl,
|
|
buildAuthHeaders,
|
|
} from "./client.js";
|
|
import { isAuthError } from "./errors.js";
|
|
import type { ResolvedCredential } from "./resolver.js";
|
|
|
|
function jsonFetch(body: unknown, status = 200): typeof fetch {
|
|
return (async () =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
})) as unknown as typeof fetch;
|
|
}
|
|
|
|
function textFetch(body: string, status: number): typeof fetch {
|
|
return (async () => new Response(body, { status })) as unknown as typeof fetch;
|
|
}
|
|
|
|
function apiKeyCred(): ResolvedCredential {
|
|
return { type: "api_key", key: "hg_x", source: "env" };
|
|
}
|
|
|
|
function makeClient(fetchImpl: typeof fetch): AuthClient {
|
|
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
|
|
}
|
|
|
|
// getCurrentUser is expected to reject with a specific auth-error code.
|
|
async function expectAuthCode(promise: Promise<unknown>, code: string): Promise<void> {
|
|
await expect(promise).rejects.toSatisfy(
|
|
(err) => isAuthError(err) && (err as { code: string }).code === code,
|
|
);
|
|
}
|
|
|
|
// getCurrentUser is expected to reject; assertMessage inspects the scrubbed message.
|
|
async function expectRejectionMessage(
|
|
client: AuthClient,
|
|
assertMessage: (msg: string) => void,
|
|
): Promise<void> {
|
|
try {
|
|
await client.getCurrentUser(apiKeyCred());
|
|
} catch (err) {
|
|
assertMessage((err as Error).message);
|
|
return;
|
|
}
|
|
throw new Error("expected rejection");
|
|
}
|
|
|
|
describe("auth/client", () => {
|
|
const original = process.env["HEYGEN_API_URL"];
|
|
|
|
beforeEach(() => {
|
|
delete process.env["HEYGEN_API_URL"];
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
|
|
else delete process.env["HEYGEN_API_URL"];
|
|
});
|
|
|
|
it("apiBaseUrl defaults to https://api.heygen.com", () => {
|
|
expect(apiBaseUrl()).toBe("https://api.heygen.com");
|
|
});
|
|
|
|
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
|
|
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
|
|
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
|
|
});
|
|
|
|
it("buildAuthHeaders uses Bearer for oauth", () => {
|
|
const cred: ResolvedCredential = {
|
|
type: "oauth",
|
|
access_token: "at_123",
|
|
source: "file_json",
|
|
refreshable: false,
|
|
};
|
|
expect(buildAuthHeaders(cred)).toEqual({
|
|
authorization: "Bearer at_123",
|
|
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
|
});
|
|
});
|
|
|
|
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header", () => {
|
|
expect(buildAuthHeaders(apiKeyCred())).toEqual({
|
|
"x-api-key": "hg_x",
|
|
});
|
|
});
|
|
|
|
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
|
|
const client = makeClient(
|
|
jsonFetch({
|
|
code: 100,
|
|
message: "ok",
|
|
data: {
|
|
username: "alice",
|
|
email: "alice@example.com",
|
|
billing_type: "subscription",
|
|
subscription: {
|
|
plan: "team",
|
|
credits: {
|
|
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
|
|
add_on_credits: { remaining: 9 },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const user = await client.getCurrentUser(apiKeyCred());
|
|
expect(user.username).toBe("alice");
|
|
expect(user.email).toBe("alice@example.com");
|
|
expect(user.subscription?.plan).toBe("team");
|
|
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
|
|
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
|
|
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
|
|
});
|
|
|
|
it("getCurrentUser parses an unwrapped payload", async () => {
|
|
const client = makeClient(jsonFetch({ email: "bob@example.com" }));
|
|
const user = await client.getCurrentUser(apiKeyCred());
|
|
expect(user.email).toBe("bob@example.com");
|
|
});
|
|
|
|
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
|
|
const client = makeClient(textFetch("invalid token", 401));
|
|
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "UNAUTHENTICATED");
|
|
});
|
|
|
|
it("getCurrentUser throws ErrApi on 5xx", async () => {
|
|
const client = makeClient(textFetch("upstream", 503));
|
|
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
|
|
});
|
|
|
|
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
|
|
const fetchImpl = (async () =>
|
|
new Response("<html>not json</html>", {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
})) as unknown as typeof fetch;
|
|
const client = makeClient(fetchImpl);
|
|
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
|
|
});
|
|
|
|
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
|
|
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
|
|
const user = await client.getCurrentUser(apiKeyCred());
|
|
expect(user).toEqual({});
|
|
});
|
|
|
|
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
|
|
const fetchImpl = textFetch(
|
|
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
|
|
401,
|
|
);
|
|
const client = makeClient(fetchImpl);
|
|
await expectRejectionMessage(client, (msg) => {
|
|
expect(msg).not.toContain("hg_supersecret_abc123");
|
|
expect(msg).toContain("<redacted>");
|
|
});
|
|
});
|
|
|
|
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
|
|
const fetchImpl = textFetch(
|
|
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
|
|
401,
|
|
);
|
|
const client = makeClient(fetchImpl);
|
|
await expectRejectionMessage(client, (msg) => {
|
|
expect(msg).not.toContain("at_opaque_secret_999");
|
|
expect(msg).not.toContain("Bearer at_opaque_secret_999");
|
|
expect(msg).toContain("<redacted>");
|
|
});
|
|
});
|
|
|
|
it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
|
|
let callCount = 0;
|
|
const observed: string[] = [];
|
|
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
callCount++;
|
|
const headers = (init?.headers as Record<string, string>) ?? {};
|
|
observed.push(headers["authorization"] ?? "");
|
|
if (callCount === 1) return new Response("expired", { status: 401 });
|
|
return new Response(JSON.stringify({ email: "a@b" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
|
|
const client = new AuthClient({
|
|
baseUrl: "https://api.test.example",
|
|
fetchImpl,
|
|
onUnauthenticatedRefresh: async () => ({
|
|
access_token: "fresh_at",
|
|
refresh_token: "fresh_rt",
|
|
}),
|
|
});
|
|
const user = await client.getCurrentUser({
|
|
type: "oauth",
|
|
access_token: "stale_at",
|
|
refresh_token: "rt",
|
|
source: "file_json",
|
|
refreshable: true,
|
|
});
|
|
expect(user.email).toBe("a@b");
|
|
expect(observed[0]).toBe("Bearer stale_at");
|
|
expect(observed[1]).toBe("Bearer fresh_at");
|
|
expect(callCount).toBe(2);
|
|
});
|
|
|
|
it("getCurrentUser hook returns full token set so the retry credential carries any rotated refresh_token", async () => {
|
|
// The hook contract now returns `OAuthTokens` (access_token plus an
|
|
// optional rotated refresh_token), not just an access_token string.
|
|
// For IdPs that rotate refresh_tokens on every exchange, any future
|
|
// retry on the in-memory credential needs the FRESH rt — the in-
|
|
// memory credential must be rebuilt from the hook's return, not
|
|
// re-use the stale rt from the original credential.
|
|
let receivedRt = "";
|
|
const fetchImpl = (async () =>
|
|
new Response("expired", { status: 401 })) as unknown as typeof fetch;
|
|
const client = new AuthClient({
|
|
baseUrl: "https://api.test.example",
|
|
fetchImpl,
|
|
onUnauthenticatedRefresh: async (rt) => {
|
|
receivedRt = rt;
|
|
// Hook MUST be able to return a rotated refresh_token — this
|
|
// would have been impossible with the old `Promise<string>`
|
|
// contract. The type checker fails the build if the shape drifts.
|
|
return { access_token: "fresh_at", refresh_token: "rotated_rt" };
|
|
},
|
|
});
|
|
await expect(
|
|
client.getCurrentUser({
|
|
type: "oauth",
|
|
access_token: "stale_at",
|
|
refresh_token: "ORIGINAL_rt",
|
|
source: "file_json",
|
|
refreshable: true,
|
|
}),
|
|
).rejects.toSatisfy((err) => isAuthError(err));
|
|
expect(receivedRt).toBe("ORIGINAL_rt");
|
|
});
|
|
|
|
it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => {
|
|
let callCount = 0;
|
|
const fetchImpl = (async () => {
|
|
callCount++;
|
|
return new Response("invalid", { status: 401 });
|
|
}) as unknown as typeof fetch;
|
|
const client = new AuthClient({
|
|
baseUrl: "https://api.test.example",
|
|
fetchImpl,
|
|
onUnauthenticatedRefresh: async () => ({ access_token: "fresh" }),
|
|
});
|
|
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
|
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
|
});
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => {
|
|
const fetchImpl = (async () =>
|
|
new Response("nope", { status: 401 })) as unknown as typeof fetch;
|
|
const { ErrRefreshFailed } = await import("./errors.js");
|
|
const client = new AuthClient({
|
|
baseUrl: "https://api.test.example",
|
|
fetchImpl,
|
|
onUnauthenticatedRefresh: async () => {
|
|
throw ErrRefreshFailed("invalid_grant");
|
|
},
|
|
});
|
|
await expect(
|
|
client.getCurrentUser({
|
|
type: "oauth",
|
|
access_token: "stale",
|
|
refresh_token: "rt",
|
|
source: "file_json",
|
|
refreshable: true,
|
|
}),
|
|
).rejects.toSatisfy((err) => {
|
|
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
|
});
|
|
});
|
|
|
|
it("getCurrentUser sends the right header for oauth credentials", async () => {
|
|
let captured: Record<string, string> = {};
|
|
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
captured = (init?.headers as Record<string, string>) ?? {};
|
|
return new Response(JSON.stringify({ email: "alice@example.com" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
|
|
const client = makeClient(fetchImpl);
|
|
await client.getCurrentUser({
|
|
type: "oauth",
|
|
access_token: "at_xyz",
|
|
source: "file_json",
|
|
refreshable: false,
|
|
});
|
|
expect(captured["authorization"]).toBe("Bearer at_xyz");
|
|
});
|
|
});
|