MessageWeave/cloudflare-worker/test/lb.test.js

242 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 单元测试:透传转发 + 有界故障转移 + 全失败 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");
});