|
1 | 1 | import { test } from "node:test"; |
2 | 2 | import assert from "node:assert/strict"; |
3 | 3 | import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | +import { createServer } from "node:http"; |
4 | 5 | import { tmpdir } from "node:os"; |
5 | 6 | import { join } from "node:path"; |
6 | 7 | import { |
7 | 8 | heygenAuthHeaders, |
8 | 9 | heygenAuthMethod, |
| 10 | + heygenBase, |
9 | 11 | heygenCredential, |
| 12 | + heygenJSON, |
10 | 13 | loadEnvFromDir, |
11 | 14 | } from "./heygen.mjs"; |
12 | 15 |
|
| 16 | +const HEYGEN_ENV = [ |
| 17 | + "HEYGEN_ACCESS_TOKEN", |
| 18 | + "HEYGEN_API_KEY", |
| 19 | + "HYPERFRAMES_API_KEY", |
| 20 | + "HEYGEN_CONFIG_DIR", |
| 21 | + "HEYGEN_API_BASE", |
| 22 | + "HEYGEN_ALLOW_HTTP", |
| 23 | +]; |
| 24 | + |
| 25 | +// Runs fn with every HeyGen variable unset, then puts them back; an async fn restores once it settles. |
13 | 26 | function withCleanHeygenEnv(fn) { |
14 | | - const previousAccessToken = process.env.HEYGEN_ACCESS_TOKEN; |
15 | | - const previousApiKey = process.env.HEYGEN_API_KEY; |
16 | | - const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY; |
17 | | - const previousConfigDir = process.env.HEYGEN_CONFIG_DIR; |
| 27 | + const previous = Object.fromEntries(HEYGEN_ENV.map((name) => [name, process.env[name]])); |
| 28 | + const restore = () => { |
| 29 | + for (const [name, value] of Object.entries(previous)) { |
| 30 | + if (value === undefined) delete process.env[name]; |
| 31 | + else process.env[name] = value; |
| 32 | + } |
| 33 | + }; |
| 34 | + for (const name of HEYGEN_ENV) delete process.env[name]; |
| 35 | + let result; |
18 | 36 | try { |
19 | | - delete process.env.HEYGEN_ACCESS_TOKEN; |
20 | | - delete process.env.HEYGEN_API_KEY; |
21 | | - delete process.env.HYPERFRAMES_API_KEY; |
22 | | - delete process.env.HEYGEN_CONFIG_DIR; |
23 | | - return fn(); |
24 | | - } finally { |
25 | | - if (previousAccessToken === undefined) delete process.env.HEYGEN_ACCESS_TOKEN; |
26 | | - else process.env.HEYGEN_ACCESS_TOKEN = previousAccessToken; |
27 | | - if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY; |
28 | | - else process.env.HEYGEN_API_KEY = previousApiKey; |
29 | | - if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY; |
30 | | - else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey; |
31 | | - if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR; |
32 | | - else process.env.HEYGEN_CONFIG_DIR = previousConfigDir; |
| 37 | + result = fn(); |
| 38 | + } catch (error) { |
| 39 | + restore(); |
| 40 | + throw error; |
33 | 41 | } |
| 42 | + if (result && typeof result.then === "function") return result.finally(restore); |
| 43 | + restore(); |
| 44 | + return result; |
34 | 45 | } |
35 | 46 |
|
36 | 47 | test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => { |
@@ -138,6 +149,27 @@ test("loadEnvFromDir skips a .env folder and loads the .env file above it", () = |
138 | 149 | } |
139 | 150 | }); |
140 | 151 |
|
| 152 | +test("a project's .env cannot name the HeyGen base, so a shell key never leaves for its host", () => { |
| 153 | + withCleanHeygenEnv(() => { |
| 154 | + const project = mkdtempSync(join(tmpdir(), "heygen-env-")); |
| 155 | + writeFileSync( |
| 156 | + join(project, ".env"), |
| 157 | + "HEYGEN_API_BASE=https://proxy.example.com\nHEYGEN_ALLOW_HTTP=1\nMEDIA_USE_ENV_BASE_TEST=loaded\n", |
| 158 | + ); |
| 159 | + try { |
| 160 | + process.env.HEYGEN_API_KEY = "hg_shell_real"; |
| 161 | + loadEnvFromDir(project); |
| 162 | + assert.equal(process.env.MEDIA_USE_ENV_BASE_TEST, "loaded"); |
| 163 | + assert.equal(process.env.HEYGEN_API_BASE, undefined); |
| 164 | + assert.equal(process.env.HEYGEN_ALLOW_HTTP, undefined); |
| 165 | + assert.equal(heygenBase(), "https://api.heygen.com/v3"); |
| 166 | + } finally { |
| 167 | + delete process.env.MEDIA_USE_ENV_BASE_TEST; |
| 168 | + rmSync(project, { recursive: true, force: true }); |
| 169 | + } |
| 170 | + }); |
| 171 | +}); |
| 172 | + |
141 | 173 | test("heygenAuthMethod returns null when the credentials path is a folder", () => { |
142 | 174 | withCleanHeygenEnv(() => { |
143 | 175 | const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); |
@@ -183,3 +215,78 @@ test("heygenAuthHeaders says to fix an unreadable credentials path, and to log i |
183 | 215 | } |
184 | 216 | }); |
185 | 217 | }); |
| 218 | + |
| 219 | +test("heygenBase is HeyGen's public API unless a host names another", () => { |
| 220 | + withCleanHeygenEnv(() => { |
| 221 | + assert.equal(heygenBase(), "https://api.heygen.com/v3"); |
| 222 | + process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com/"; |
| 223 | + assert.equal(heygenBase(), "https://api-canary.heygen.com/v3"); |
| 224 | + }); |
| 225 | +}); |
| 226 | + |
| 227 | +test("heygenBase refuses a plain-HTTP host unless HEYGEN_ALLOW_HTTP is set, as the heygen CLI does", () => { |
| 228 | + withCleanHeygenEnv(() => { |
| 229 | + process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100"; |
| 230 | + assert.throws(() => heygenBase(), /HEYGEN_ALLOW_HTTP=1/); |
| 231 | + process.env.HEYGEN_ALLOW_HTTP = "1"; |
| 232 | + assert.equal(heygenBase(), "http://127.0.0.1:4100/v3"); |
| 233 | + }); |
| 234 | +}); |
| 235 | + |
| 236 | +test("a host gateway (HEYGEN_API_BASE with its own HEYGEN_API_KEY) wins over a host OAuth token", () => { |
| 237 | + withCleanHeygenEnv(() => { |
| 238 | + process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100"; |
| 239 | + process.env.HEYGEN_ALLOW_HTTP = "1"; |
| 240 | + process.env.HEYGEN_API_KEY = "gateway-token"; |
| 241 | + process.env.HEYGEN_ACCESS_TOKEN = "at_host"; |
| 242 | + assert.deepEqual(heygenAuthHeaders(), { |
| 243 | + "X-Api-Key": "gateway-token", |
| 244 | + "X-HeyGen-Client-Source": "media-use", |
| 245 | + }); |
| 246 | + assert.equal(heygenAuthMethod(), "api_key"); |
| 247 | + }); |
| 248 | +}); |
| 249 | + |
| 250 | +test("heygenJSON sends its request to the host's HEYGEN_API_BASE with the host's key", async () => { |
| 251 | + /** @type {{ url?: string, key?: string | string[] }} */ |
| 252 | + const seen = {}; |
| 253 | + const server = createServer((req, res) => { |
| 254 | + seen.url = req.url; |
| 255 | + seen.key = req.headers["x-api-key"]; |
| 256 | + res.writeHead(200, { "content-type": "application/json" }).end('{"data":[]}'); |
| 257 | + }); |
| 258 | + await new Promise((done) => server.listen(0, "127.0.0.1", done)); |
| 259 | + const { port } = /** @type {import("node:net").AddressInfo} */ (server.address()); |
| 260 | + try { |
| 261 | + await withCleanHeygenEnv(async () => { |
| 262 | + process.env.HEYGEN_API_BASE = `http://127.0.0.1:${port}`; |
| 263 | + process.env.HEYGEN_ALLOW_HTTP = "1"; |
| 264 | + process.env.HEYGEN_API_KEY = "gateway-token"; |
| 265 | + const reply = await heygenJSON("/voices?limit=1", { headers: heygenAuthHeaders() }); |
| 266 | + assert.deepEqual(reply, { data: [] }); |
| 267 | + }); |
| 268 | + assert.equal(seen.url, "/v3/voices?limit=1"); |
| 269 | + assert.equal(seen.key, "gateway-token"); |
| 270 | + } finally { |
| 271 | + server.close(); |
| 272 | + } |
| 273 | +}); |
| 274 | + |
| 275 | +test("a base that isn't HeyGen's gets no stored or host OAuth credential, only a key named for it", () => { |
| 276 | + withCleanHeygenEnv(() => { |
| 277 | + const dir = mkdtempSync(join(tmpdir(), "heygen-cred-")); |
| 278 | + try { |
| 279 | + process.env.HEYGEN_CONFIG_DIR = dir; |
| 280 | + writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "hg_stored" })); |
| 281 | + process.env.HEYGEN_ACCESS_TOKEN = "at_host"; |
| 282 | + process.env.HEYGEN_API_BASE = "https://proxy.example.com"; |
| 283 | + assert.equal(heygenCredential(), null); |
| 284 | + assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/); |
| 285 | + // HeyGen's own hosts keep every credential source. |
| 286 | + process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com"; |
| 287 | + assert.equal(heygenAuthMethod(), "oauth"); |
| 288 | + } finally { |
| 289 | + rmSync(dir, { recursive: true, force: true }); |
| 290 | + } |
| 291 | + }); |
| 292 | +}); |
0 commit comments