242 lines
9.7 KiB
JavaScript
242 lines
9.7 KiB
JavaScript
/**
|
||
* 单元测试:透传转发 + 有界故障转移 + 全失败 5xx(SAF-LB-PASSTHRU / SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE)。
|
||
* 运行:cd cloudflare-worker && node --test
|
||
*/
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { forwardWithFailover, joinUrl, isRetryableError } from "../src/lb.js";
|
||
|
||
/**
|
||
* 可控 fetch mock:steps[i] 每次调用消费一个;
|
||
* - {status, body?} → 返回该 Response;
|
||
* - {error: Error} → 抛出。
|
||
* - {response: Response} → 原样返回该对象(用于模拟 workerd 特有行为,如不可变响应头);
|
||
*/
|
||
function mockFetch(steps) {
|
||
const calls = [];
|
||
const fetch = async (url, init) => {
|
||
const i = calls.length;
|
||
calls.push({ url, init });
|
||
const step = steps[Math.min(i, steps.length - 1)];
|
||
if (step.error) throw step.error;
|
||
if (step.response) return step.response;
|
||
return new Response(step.body ?? "ok", { status: step.status, headers: { "content-type": "text/plain" } });
|
||
};
|
||
return { fetch, calls };
|
||
}
|
||
|
||
/** 测试用计时器:不真触发超时。 */
|
||
const noOpTimers = { setTimeout: () => 0, clearTimeout: () => {} };
|
||
|
||
/**
|
||
* 模拟「不可变响应头」:跨域且不带 CORS 允许头的响应,其 headers guard 是 immutable,
|
||
* 任何写操作都抛 `TypeError: Can't modify immutable headers.`(workerd 实测行为)。
|
||
* 生产 LB 代理后端 `/` 正是这种响应。
|
||
*/
|
||
function immutableResponse(status, body = "healthy") {
|
||
return {
|
||
status,
|
||
headers: {
|
||
get() { return null; },
|
||
set() { throw new TypeError("Can't modify immutable headers."); },
|
||
append() { throw new TypeError("Can't modify immutable headers."); },
|
||
},
|
||
body: undefined,
|
||
text: async () => body,
|
||
};
|
||
}
|
||
|
||
function req(path = "/reconcile") {
|
||
return new Request(`https://lb.example${path}`, {
|
||
method: "POST",
|
||
headers: { authorization: "Bearer secret-token", "content-type": "application/json" },
|
||
body: JSON.stringify({ id: 42 }),
|
||
});
|
||
}
|
||
|
||
function abortError() {
|
||
const err = new Error("The operation was aborted");
|
||
err.name = "AbortError";
|
||
return err;
|
||
}
|
||
|
||
test("isRetryableError: only 5xx (status) and timeout/network (err) are retryable", () => {
|
||
assert.equal(isRetryableError(new Error("boom"), 0), false, "generic error not retryable");
|
||
assert.equal(isRetryableError(abortError(), 0), true, "AbortError retryable");
|
||
assert.equal(isRetryableError(new Error("fetch failed"), 0), true, "network-class message retryable");
|
||
assert.equal(isRetryableError(new Error("bad inner state"), 0), false, "non-network error not retryable");
|
||
assert.equal(isRetryableError(null, 503), true, "5xx retryable");
|
||
assert.equal(isRetryableError(null, 404), false, "4xx not retryable");
|
||
assert.equal(isRetryableError(null, 200), false, "2xx not retryable");
|
||
});
|
||
|
||
test("transparent passthrough forwards headers + body + method (SAF-LB-PASSTHRU)", async () => {
|
||
const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]);
|
||
const res = await forwardWithFailover(["https://a.example"], req("/webhook/tg"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0.5,
|
||
});
|
||
assert.equal(res.status, 202);
|
||
assert.equal(calls.length, 1);
|
||
assert.equal(calls[0].url, "https://a.example/webhook/tg");
|
||
assert.equal(calls[0].init.method, "POST");
|
||
assert.equal(calls[0].init.headers.get("authorization"), "Bearer secret-token");
|
||
assert.equal(calls[0].init.headers.get("content-type"), "application/json");
|
||
const body = await new Response(new Uint8Array(calls[0].init.body)).text();
|
||
assert.equal(body, JSON.stringify({ id: 42 }));
|
||
assert.equal(res.headers.get("x-lb-backend"), "https://a.example");
|
||
});
|
||
|
||
test("immutable response headers never mask a healthy 2xx as 503 (跨域无 CORS)", async () => {
|
||
// 回归:2026-09-30 生产事故。后端返回健康的 200,但该响应跨域且无 CORS 头,
|
||
// headers guard 为 immutable;mark() 写 `x-lb-backend` 抛 TypeError,被外层 catch
|
||
// 记成 `backend ... unavailable (TypeError)` → 重试耗尽 → 503。/healthz 自建响应
|
||
// 不调 mark(),所以一直正常,掩盖了问题。
|
||
const { fetch, calls } = mockFetch([{ response: immutableResponse(200) }]);
|
||
const res = await forwardWithFailover(["https://a.example"], req("/"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 200, "healthy 200 must be returned as-is");
|
||
assert.equal(calls.length, 1, "a healthy response must not trigger failover");
|
||
assert.equal(await res.text(), "healthy");
|
||
});
|
||
|
||
test("immutable headers are tolerated on the 5xx failover path too", async () => {
|
||
const { fetch, calls } = mockFetch([{ response: immutableResponse(503) }, { status: 200, body: "ok" }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 200, "503 with immutable headers still fails over");
|
||
assert.equal(calls.length, 2);
|
||
assert.equal(res.headers.get("x-lb-backend"), "https://b.example", "marker still added on a mutable response");
|
||
});
|
||
|
||
test("GET/HEAD do not forward a body", async () => {
|
||
const { fetch, calls } = mockFetch([{ status: 200 }]);
|
||
const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } });
|
||
await forwardWithFailover(["https://a.example"], get, {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 1,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(calls[0].init.body, undefined, "GET must not carry a body");
|
||
});
|
||
|
||
test("4xx is returned as-is WITHOUT retry and without failing over", async () => {
|
||
const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 401);
|
||
assert.equal(calls.length, 1, "must NOT retry/share next backend on 4xx");
|
||
assert.equal(calls[0].url, "https://a.example/push/jmap");
|
||
});
|
||
|
||
test("5xx triggers failover to next origin, then returns success", async () => {
|
||
const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0, // 确定性起点 a
|
||
});
|
||
assert.equal(res.status, 200);
|
||
assert.equal(calls.length, 2);
|
||
assert.equal(calls[0].url, "https://a.example/push/jmap");
|
||
assert.equal(calls[1].url, "https://b.example/push/jmap");
|
||
assert.equal(res.headers.get("x-lb-backend"), "https://b.example");
|
||
});
|
||
|
||
test("timeout(AbortError) triggers failover and recovery", async () => {
|
||
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 200);
|
||
assert.equal(calls.length, 2);
|
||
});
|
||
|
||
test("all backends fail: HTTP 5xx 透传最后一次真实 body(不被合成文本覆盖)", async () => {
|
||
// 网络错误 + HTTP 5xx 混合:保留 5xx 响应并透传,客户端能看到后端真实错误 body。
|
||
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 503);
|
||
assert.equal(calls.length, 2);
|
||
assert.equal(await res.text(), "x", "5xx 透传:后端真实 body 不被合成文本覆盖");
|
||
assert.equal(res.headers.get("x-lb-backend"), "https://b.example");
|
||
});
|
||
|
||
test("all backends network-error only => 合成 503 (Telegram redelivery fallback)", async () => {
|
||
// 纯网络错误(无 HTTP 响应)→ 无真实 body 可透传 → 合成 503。
|
||
const { fetch, calls } = mockFetch([{ error: abortError() }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 503);
|
||
assert.equal(calls.length, 2);
|
||
assert.match(await res.text(), /all backends failed/);
|
||
});
|
||
|
||
test("bounded attempts: maxAttempts caps the number of origin tries", async () => {
|
||
const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移)
|
||
const res = await forwardWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 503);
|
||
assert.equal(calls.length, 2, "must stop after maxAttempts=2 (bounded, no infinite retry)");
|
||
});
|
||
|
||
test("non-retryable (non-network) error fails fast without burning other origins", async () => {
|
||
const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]);
|
||
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||
...noOpTimers,
|
||
fetch,
|
||
maxAttempts: 2,
|
||
timeoutMs: 5000,
|
||
rng: () => 0,
|
||
});
|
||
assert.equal(res.status, 503);
|
||
assert.equal(calls.length, 1, "non-network error must not failover");
|
||
});
|
||
|
||
test("joinUrl preserves path + query, discards fragment", () => {
|
||
const r = new Request("https://lb.example/reconcile?cursor=abc#frag", { method: "POST" });
|
||
assert.equal(joinUrl("https://a.example", r), "https://a.example/reconcile?cursor=abc");
|
||
});
|