9352f10d0d
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
|
|
|
|
const caddyBin = process.env.CADDY_BIN;
|
|
const integrationTest = caddyBin ? test : test.skip;
|
|
const cleanup: Array<() => void | Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
while (cleanup.length > 0) await cleanup.pop()?.();
|
|
});
|
|
|
|
test("one YAML response budget owns Caddy retry and bridge cannot reset it", () => {
|
|
const timing = { maxRetries: 1, tryIntervalMs: 250, dialTimeoutMs: 500, writeTimeoutMs: 500 };
|
|
expect(() => validateGiteaWebhookTiming(6500, timing)).not.toThrow();
|
|
expect(() => validateGiteaWebhookTiming(9000, timing)).toThrow(/below 9000/);
|
|
expect(() => validateGiteaWebhookTiming(6500, { ...timing, maxRetries: 2 })).toThrow(/maxRetries must be 1/);
|
|
expect(deriveGiteaWebhookAttemptBudgetMs(6500, { maxRetries: 1, tryIntervalMs: 250 })).toBe(3125);
|
|
expect(deriveGiteaWebhookResponseHeaderTimeoutMs(6500, timing)).toBe(2125);
|
|
const route = renderGiteaWebhookCaddyHandle(
|
|
{ publicPath: "/hook", remotePort: 8080 },
|
|
{ enabled: true, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
|
|
6500,
|
|
600,
|
|
);
|
|
expect(route).toContain("lb_try_duration 6500ms");
|
|
expect(route).toContain("dial_timeout 500ms");
|
|
expect(route).toContain("write_timeout 500ms");
|
|
expect(route).toContain("response_header_timeout 2125ms");
|
|
expect(route).toContain("request_buffers 65");
|
|
const disabled = renderGiteaWebhookCaddyHandle(
|
|
{ publicPath: "/hook", remotePort: 8080 },
|
|
{ enabled: false, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
|
|
6500,
|
|
600,
|
|
);
|
|
expect(disabled).toContain("response_header_timeout 600s");
|
|
expect(disabled).not.toContain("lb_retries");
|
|
});
|
|
|
|
integrationTest("Caddy replays exact POST bytes for retryable responses and rejects oversized bodies with 413", async () => {
|
|
const version = spawnSync(caddyBin!, ["version"], { encoding: "utf8" });
|
|
expect(`${version.stdout}${version.stderr}`).toMatch(/v2\.11\./);
|
|
|
|
const backendPort = await freePort();
|
|
const caddyPort = await freePort();
|
|
let mode: "retry-once" | "client-error" | "exhaust" | "slow-budget" = "retry-once";
|
|
let hits = 0;
|
|
let bodies: Buffer[] = [];
|
|
const backendTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
const backendHandler = (req: IncomingMessage, res: ServerResponse) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
req.on("end", () => {
|
|
hits += 1;
|
|
const body = Buffer.concat(chunks);
|
|
bodies.push(body);
|
|
if (mode === "slow-budget" && hits === 1) {
|
|
const timer = setTimeout(() => {
|
|
backendTimers.delete(timer);
|
|
res.writeHead(503).end("slow-retry");
|
|
}, 180);
|
|
backendTimers.add(timer);
|
|
} else if (mode === "slow-budget") {
|
|
const timer = setTimeout(() => {
|
|
backendTimers.delete(timer);
|
|
if (!res.destroyed) res.writeHead(200).end("too-late");
|
|
}, 600);
|
|
backendTimers.add(timer);
|
|
} else if (mode === "retry-once" && hits === 1) {
|
|
res.writeHead(503).end("retry");
|
|
} else if (mode === "client-error") {
|
|
res.writeHead(422).end("invalid");
|
|
} else if (mode === "exhaust") {
|
|
res.writeHead(503).end("unavailable");
|
|
} else {
|
|
res.writeHead(200, { "content-type": "application/octet-stream" }).end(body);
|
|
}
|
|
});
|
|
};
|
|
let backend = createServer(backendHandler);
|
|
await listen(backend, backendPort);
|
|
cleanup.push(() => close(backend));
|
|
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-caddy-retry-test-"));
|
|
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
|
|
const route = renderGiteaWebhookCaddyHandle(
|
|
{ publicPath: "/hook", remotePort: backendPort },
|
|
{ enabled: true, maxBodyBytes: 64, maxRetries: 1, tryIntervalMs: 50, dialTimeoutMs: 20, writeTimeoutMs: 20, retryStatusCodes: [502, 503, 504] },
|
|
500,
|
|
600,
|
|
);
|
|
const caddyfile = join(root, "Caddyfile");
|
|
writeFileSync(caddyfile, `{
|
|
admin off
|
|
auto_https off
|
|
}
|
|
http://127.0.0.1:${caddyPort} {
|
|
${route}
|
|
}
|
|
`);
|
|
const caddy = spawn(caddyBin!, ["run", "--config", caddyfile, "--adapter", "caddyfile"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
const stderr: Buffer[] = [];
|
|
caddy.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
cleanup.push(async () => {
|
|
caddy.kill("SIGTERM");
|
|
await Promise.race([new Promise((resolve) => caddy.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 1000))]);
|
|
});
|
|
await waitForCaddy(caddyPort, stderr);
|
|
|
|
const exactBody = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
|
|
let response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
expect(response.status).toBe(200);
|
|
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
|
|
expect(hits).toBe(2);
|
|
expect(bodies).toEqual([exactBody, exactBody]);
|
|
|
|
mode = "client-error";
|
|
hits = 0;
|
|
bodies = [];
|
|
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
expect(response.status).toBe(422);
|
|
expect(hits).toBe(1);
|
|
|
|
mode = "exhaust";
|
|
hits = 0;
|
|
bodies = [];
|
|
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
expect(response.status).toBe(503);
|
|
expect(hits).toBe(2);
|
|
expect(bodies).toEqual([exactBody, exactBody]);
|
|
|
|
hits = 0;
|
|
bodies = [];
|
|
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: Buffer.alloc(65, 0x5a) });
|
|
expect(response.status).toBe(413);
|
|
expect(hits).toBe(0);
|
|
|
|
mode = "retry-once";
|
|
hits = 1;
|
|
bodies = [];
|
|
await close(backend);
|
|
const recoveringRequest = fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
backend = createServer(backendHandler);
|
|
await listen(backend, backendPort);
|
|
response = await recoveringRequest;
|
|
expect(response.status).toBe(200);
|
|
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
|
|
expect(bodies).toEqual([exactBody]);
|
|
|
|
mode = "slow-budget";
|
|
hits = 0;
|
|
bodies = [];
|
|
const startedAt = performance.now();
|
|
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
const elapsedMs = performance.now() - startedAt;
|
|
expect([502, 504]).toContain(response.status);
|
|
expect(hits).toBe(2);
|
|
expect(elapsedMs).toBeGreaterThanOrEqual(350);
|
|
expect(elapsedMs).toBeLessThan(700);
|
|
for (const timer of backendTimers) clearTimeout(timer);
|
|
backendTimers.clear();
|
|
|
|
await close(backend);
|
|
hits = 0;
|
|
const unavailableStartedAt = performance.now();
|
|
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
|
|
const unavailableElapsedMs = performance.now() - unavailableStartedAt;
|
|
expect(response.status).toBe(502);
|
|
expect(hits).toBe(0);
|
|
expect(unavailableElapsedMs).toBeLessThan(700);
|
|
});
|
|
|
|
async function freePort(): Promise<number> {
|
|
const server = createServer();
|
|
await listen(server, 0);
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
await close(server);
|
|
return port;
|
|
}
|
|
|
|
async function listen(server: Server, port: number): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(port, "127.0.0.1", resolve);
|
|
});
|
|
}
|
|
|
|
async function close(server: Server): Promise<void> {
|
|
if (!server.listening) return;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
async function waitForCaddy(port: number, stderr: Buffer[]): Promise<void> {
|
|
const deadline = Date.now() + 4000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/not-a-hook`);
|
|
return;
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
}
|
|
}
|
|
throw new Error(`Caddy did not start: ${Buffer.concat(stderr).toString("utf8")}`);
|
|
}
|