Files
web_hgdjc_h5/tests/api-config.test.mjs
T
2026-11-07 11:18:11 +07:00

427 lines
14 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { isIP } from "node:net";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
import ts from "typescript";
const environment = { PUBLIC_DATA_CACHE_TTL_MS: "60000" };
const sharedDependencies = { "node:net": { isIP } };
async function compile(path, dependencies = {}, extra = {}) {
const exports = {};
const { outputText } = ts.transpileModule(
await readFile(
new URL("../src/lib/api/" + path + ".ts", import.meta.url),
"utf8",
),
{
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
},
},
);
vm.runInNewContext(outputText, {
exports,
URL,
Headers,
Request,
Response,
FormData,
AbortSignal,
TextDecoder,
Buffer,
structuredClone,
process: { env: environment },
require: (name) => {
if (name in dependencies) return dependencies[name];
if (name in sharedDependencies) return sharedDependencies[name];
throw new Error("Unknown dependency: " + name);
},
...extra,
});
return exports;
}
const config = await compile("config");
const protocol = await compile("protocol");
const cache = await compile("public-cache");
sharedDependencies["./user-agent"] = await compile("user-agent");
sharedDependencies["./sdk-headers"] = await compile("sdk-headers");
sharedDependencies["./client-headers"] = await compile("client-headers");
sharedDependencies["../site-navigation"] = await compile("../site-navigation", {
"./api/protocol": protocol,
"./routing": await compile("../routing"),
});
test("development uses its configured domain and supports legacy overrides", () => {
delete environment.UPSTREAM_API_URL;
delete environment.API_BASE_URL;
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
environment.APP_ENV = "test";
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
environment.APP_ENV = "production";
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
delete environment.APP_ENV;
environment.API_BASE_URL = "https://legacy-api.example/";
assert.equal(config.getApiBaseUrl(), "https://legacy-api.example");
environment.UPSTREAM_API_URL = " http://127.0.0.1:21111/ ";
assert.equal(config.getApiBaseUrl(), "http://127.0.0.1:21111");
for (const value of [
"",
"javascript:bad",
"https://user:secret@api.example",
"https://api.example/?query=1",
"https://api.example/#fragment",
]) {
environment.UPSTREAM_API_URL = value;
assert.throws(() => config.getApiBaseUrl(), /UPSTREAM_API_URL/);
}
});
test("explicit current mode resolves the current domain per request, including protocol and port", () => {
environment.UPSTREAM_API_URL = "current";
delete environment.API_BASE_URL;
for (const APP_ENV of ["test", "production"]) {
environment.APP_ENV = APP_ENV;
assert.equal(config.usesCurrentApiOrigin(), true);
assert.equal(
config.getApiBaseUrl({
url: "http://0.0.0.0:21030/api/app/config/info",
headers: new Headers({
Host: "first.example:8443",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "ignored.example",
}),
}),
"https://first.example:8443",
);
assert.equal(
config.getApiBaseUrl({
url: "http://second.example:21030/api/app/config/info",
headers: new Headers(),
}),
"http://second.example:21030",
);
assert.equal(
config.getApiBaseUrl({
headers: new Headers({
"X-Forwarded-Host": "fallback.example",
"X-Forwarded-Proto": "https",
}),
}),
"https://fallback.example",
);
assert.throws(() => config.getApiBaseUrl(), /请求域名/);
for (const Host of [
"user:secret@api.test",
"api.test/path",
"api.test?x=1",
"api.test,other.test",
"api.test\\other",
]) {
assert.throws(() =>
config.getApiBaseUrl({ headers: new Headers({ Host }) }),
);
}
assert.throws(() =>
config.getApiBaseUrl({
headers: new Headers({ Host: "api.test", "X-Forwarded-Proto": "ftp" }),
}),
);
}
delete environment.APP_ENV;
});
test("SSR and API proxy share the configured upstream while cache entries stay isolated by backend", async () => {
const requests = [];
const incoming = new Headers({
"User-Agent": "visitor-ua",
"X-Real-IP": "203.0.113.9",
});
const server = await compile(
"server",
{
"./protocol": protocol,
"./config": config,
"./public-cache": cache,
react: { cache: (fn) => fn },
"next/headers": { headers: async () => incoming },
},
{
fetch: async (url, options) => {
requests.push({ url: String(url), ...options });
return Response.json({
code: 200,
data: { source: new URL(url).origin },
});
},
},
);
environment.UPSTREAM_API_URL = "https://api-a.example";
const first = await server.upstream("config/info");
assert.equal(first.data.source, "https://api-a.example");
assert.equal(requests[0].headers["User-Agent"], "visitor-ua");
assert.equal(requests[0].headers["X-Real-IP"], "203.0.113.9");
environment.UPSTREAM_API_URL = "https://api-b.example";
assert.equal(
(await server.upstream("config/info")).data.source,
"https://api-b.example",
);
environment.UPSTREAM_API_URL = "https://api-a.example";
assert.equal(
(await server.upstream("config/info")).data.source,
"https://api-a.example",
);
assert.equal(requests.length, 2);
await server.upstream("auth/getInfo", { token: "server-only-token" });
assert.equal(
requests.at(-1).url,
"https://api-a.example/api/app/auth/getInfo",
);
assert.equal(requests.at(-1).headers.Authorization, "server-only-token");
});
test("SSR, API handlers and private requests use the current origin without mixing public caches", async () => {
environment.UPSTREAM_API_URL = "current";
let incoming = new Headers({
Host: "site-a.example",
"X-Forwarded-Proto": "https",
});
const requests = [];
const server = await compile(
"server",
{
"./protocol": protocol,
"./config": config,
"./public-cache": cache,
react: { cache: (fn) => fn },
"next/headers": { headers: async () => incoming },
},
{
fetch: async (url, options) => {
requests.push({ url: String(url), ...options });
return Response.json({
code: 200,
data: { source: new URL(url).origin },
});
},
},
);
assert.equal(
(await server.upstream("config/info")).data.source,
"https://site-a.example",
);
incoming = new Headers({
Host: "site-b.example",
"X-Forwarded-Proto": "http",
});
assert.equal(
(await server.upstream("config/info")).data.source,
"http://site-b.example",
);
const request = new Request("https://site-a.example/api/app/config/info");
assert.equal(
(await server.upstream("config/info", { request })).data.source,
"https://site-a.example",
);
assert.equal(requests.length, 2);
await server.upstream("auth/getInfo", {
request,
token: "private-current-token",
});
assert.equal(
requests.at(-1).url,
"https://site-a.example/api/app/auth/getInfo",
);
assert.equal(requests.at(-1).headers.Authorization, "private-current-token");
assert.equal(requests.at(-1).headers[config.UPSTREAM_HOP_HEADER], "1");
});
test("current-origin media preserves signatures and fails immediately if the upstream returns to Next", async () => {
environment.UPSTREAM_API_URL = "current";
const requests = [];
const media = await compile(
"media",
{ "node:net": { isIP }, "./config": config },
{
fetch: async (url, options) => {
requests.push({ url: String(url), ...options });
return new Response(new Uint8Array(16));
},
},
);
const request = new Request(
"http://0.0.0.0:21030/enkey?id=1&ticket=a%2Fb+1",
{
headers: {
Host: "deployed.example",
"X-Forwarded-Proto": "https",
Cookie: "drama_session=secret",
},
},
);
assert.equal((await media.proxyMedia(request, "/enkey", true)).status, 200);
assert.equal(
requests[0].url,
"https://deployed.example/enkey?id=1&ticket=a%2Fb+1",
);
assert.equal(requests[0].headers.get("cookie"), null);
assert.equal(requests[0].headers.get(config.UPSTREAM_HOP_HEADER), "1");
const loop = new Request("https://deployed.example/enkey", {
headers: { [config.UPSTREAM_HOP_HEADER]: "1" },
});
const response = await media.proxyMedia(loop, "/enkey", true);
assert.equal(response.status, 502);
assert.match(await response.text(), /转发配置/);
assert.equal(requests.length, 1);
});
test("media and keys use the configured upstream while preserving signatures, byte ranges and credential isolation", async () => {
const reads = [];
const media = await compile(
"media",
{ "node:net": { isIP }, "./config": config },
{
fetch: async (url, options) => {
reads.push({ url: String(url), headers: options.headers });
return new Response(
new URL(url).pathname === "/enkey/"
? new Uint8Array(16)
: "#EXTM3U\n#EXT-X-ENDLIST\n",
);
},
},
);
environment.UPSTREAM_API_URL = "http://127.0.0.1:21111";
const incoming = new Request(
"https://current-site.example/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
{
headers: {
Range: "bytes=0-2",
Cookie: "drama_session=secret",
Authorization: "secret",
"X-Real-IP": "203.0.113.4",
},
},
);
assert.equal(
(await media.proxyMedia(incoming, "/h5/m3u8/video.m3u8")).status,
200,
);
assert.equal(
reads.at(-1).url,
"http://127.0.0.1:21111/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
);
assert.equal(reads.at(-1).headers.get("range"), "bytes=0-2");
assert.equal(reads.at(-1).headers.get("x-real-ip"), "203.0.113.4");
assert.equal(reads.at(-1).headers.get("x-forwarded-for"), null);
assert.equal(reads.at(-1).headers.get("authorization"), null);
assert.equal(reads.at(-1).headers.get("cookie"), null);
const key = await media.proxyMedia(
new Request("https://current-site.example/enkey?token=a%2Fb"),
"/enkey/",
true,
);
assert.equal(key.status, 200);
assert.equal((await key.arrayBuffer()).byteLength, 16);
assert.equal(reads.at(-1).url, "http://127.0.0.1:21111/enkey/?token=a%2Fb");
});
test("key proxies follow one same-origin slash change in either direction without changing signed queries or headers", async () => {
environment.UPSTREAM_API_URL = "https://key-api.example";
const query = "?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy";
for (const pathname of ["/enkey", "/enkey/", "/newenkey", "/newenkey/"]) {
const alternate = pathname.endsWith("/")
? pathname.slice(0, -1)
: pathname + "/";
const reads = [];
const media = await compile(
"media",
{ "node:net": { isIP }, "./config": config },
{
fetch: async (url, options) => {
reads.push({ url: String(url), ...options });
return reads.length === 1
? new Response("", {
status: 301,
headers: {
Location: alternate + (pathname.endsWith("/") ? "" : query),
},
})
: new Response(new Uint8Array(16));
},
},
);
const result = await media.proxyMedia(
new Request("http://local.test" + pathname + query, {
headers: {
"X-Real-IP": "203.0.113.7",
"User-Agent": "key-test",
Cookie: "drama_session=private",
Authorization: "private",
},
}),
pathname,
true,
);
assert.equal(result.status, 200);
assert.equal((await result.arrayBuffer()).byteLength, 16);
assert.deepEqual(
reads.map((item) => item.url),
[
"https://key-api.example" + pathname + query,
"https://key-api.example" + alternate + query,
],
);
for (const read of reads) {
assert.equal(read.redirect, "manual");
assert.equal(read.cache, "no-store");
assert.equal(read.headers.get("X-Real-IP"), "203.0.113.7");
assert.equal(read.headers.get("User-Agent"), "key-test");
assert.equal(
read.headers.get("X-User-Agent"),
"DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=;device_brand=;device_model=;system_name=Other;system_version=;sid=",
);
assert.equal(read.headers.get("Cookie"), null);
assert.equal(read.headers.get("Authorization"), null);
}
assert.equal(reads[0].signal, reads[1].signal);
}
});
test("key proxies reject unrelated, cross-origin and repeated redirects before forwarding credentials or signatures", async () => {
environment.UPSTREAM_API_URL = "https://key-api.example";
for (const [location, repeated] of [
["https://external.example/enkey/", false],
["/newenkey/", false],
["/enkey/?ticket=changed", false],
["https://user:secret@key-api.example/enkey/", false],
["/enkey/", true],
[null, false],
]) {
const reads = [];
const media = await compile(
"media",
{ "node:net": { isIP }, "./config": config },
{
fetch: async (url) => {
reads.push(String(url));
return new Response("", {
status: 302,
headers: location
? { Location: reads.length === 1 ? location : "/enkey" }
: {},
});
},
},
);
const result = await media.proxyMedia(
new Request("http://local.test/enkey?ticket=original"),
"/enkey",
true,
);
assert.equal(result.status, 502, String(location));
assert.equal(reads.length, repeated ? 2 : 1, String(location));
}
});