Skip to content

Commit 7591aec

Browse files
committed
fix(ci): bound integration network probes
1 parent f2db667 commit 7591aec

7 files changed

Lines changed: 104 additions & 25 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, test } from "bun:test";
3+
4+
describe("integration scenario timeout guardrails", () => {
5+
const commonSource = readFileSync(new URL("./integration/scenarios/common.ts", import.meta.url), "utf8");
6+
7+
test("logs and bounds LXD API verification after identity-provider switches", () => {
8+
expect(commonSource).toContain("verify ${host.label} LXD API");
9+
expect(commonSource).toContain("verified ${host.label} LXD API");
10+
expect(commonSource).toContain("external OIDC LXD API for ${host.label}");
11+
expect(commonSource).toContain("local ZITADEL LXD API for ${host.label}");
12+
expect(commonSource.match(/verifyLxdApi\(host, context\)/g)?.length).toBe(2);
13+
});
14+
});

tests/integration/assertions/http.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, test } from "bun:test";
2-
import { parseCurlHttpBodyResult } from "./http";
3+
import { CURL_PROCESS_TIMEOUT_MS, HTTP_FETCH_TIMEOUT_MS, parseCurlHttpBodyResult } from "./http";
34

45
describe("HTTP assertion helpers", () => {
56
test("uses curl's final status output instead of header-looking body content", () => {
@@ -22,4 +23,14 @@ describe("HTTP assertion helpers", () => {
2223
expect(() => parseCurlHttpBodyResult("", "body")).toThrow("valid final HTTP status");
2324
expect(() => parseCurlHttpBodyResult("HTTP/2 302", "body")).toThrow("valid final HTTP status");
2425
});
26+
27+
test("bounds every network attempt below the outer poll deadline", () => {
28+
const source = readFileSync(new URL("./http.ts", import.meta.url), "utf8");
29+
30+
expect(HTTP_FETCH_TIMEOUT_MS).toBeLessThan(30000);
31+
expect(CURL_PROCESS_TIMEOUT_MS).toBeGreaterThan(HTTP_FETCH_TIMEOUT_MS);
32+
expect(source.match(/timeoutMs: CURL_PROCESS_TIMEOUT_MS/g)?.length).toBe(2);
33+
expect(source.match(/await fetchWithTimeout\(url/g)?.length).toBe(1);
34+
expect(source.match(/await fetchTextWithTimeout\(url/g)?.length).toBe(1);
35+
});
2536
});

tests/integration/assertions/http.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ type HttpAssertionOptions = {
1111

1212
type JsonValidator = (value: unknown) => void;
1313

14+
export const HTTP_FETCH_TIMEOUT_MS = 20000;
15+
export const CURL_PROCESS_TIMEOUT_MS = 30000;
16+
const HTTP_POLL_INTERVAL_MS = 5000;
17+
1418
function curlResolveArgs(url: string, resolveIp?: string): string[] {
1519
if (!resolveIp) {
1620
return [];
@@ -29,21 +33,43 @@ export function parseCurlHttpBodyResult(stdout: string, body: string): { status:
2933
return { status, body };
3034
}
3135

36+
async function withFetchTimeout<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
37+
const controller = new AbortController();
38+
const timer = setTimeout(() => controller.abort(), HTTP_FETCH_TIMEOUT_MS);
39+
try {
40+
return await task(controller.signal);
41+
} finally {
42+
clearTimeout(timer);
43+
}
44+
}
45+
46+
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
47+
return await withFetchTimeout((signal) => fetch(url, { ...init, signal }));
48+
}
49+
50+
async function fetchTextWithTimeout(url: string, init: RequestInit): Promise<{ response: Response; body: string }> {
51+
return await withFetchTimeout(async (signal) => {
52+
const response = await fetch(url, { ...init, signal });
53+
const body = await response.text();
54+
return { response, body };
55+
});
56+
}
57+
3258
/** Polls an HTTP endpoint until it returns one of the expected status codes. */
3359
export async function waitForHttpStatus(url: string, expectedStatuses: number[], timeoutMs = 180000): Promise<Response> {
3460
const deadline = Date.now() + timeoutMs;
3561
let lastResponse: Response | null = null;
3662
while (Date.now() < deadline) {
3763
try {
38-
const response = await fetch(url, { redirect: "manual" });
64+
const response = await fetchWithTimeout(url, { redirect: "manual" });
3965
lastResponse = response;
4066
if (expectedStatuses.includes(response.status)) {
4167
return response;
4268
}
4369
} catch {
4470
// Ignore transient DNS/TLS startup errors while services converge.
4571
}
46-
await Bun.sleep(5000);
72+
await Bun.sleep(HTTP_POLL_INTERVAL_MS);
4773
}
4874
throw new Error(`timed out waiting for ${url} to return one of [${expectedStatuses.join(", ")}], last status: ${lastResponse?.status ?? "none"}`);
4975
}
@@ -70,7 +96,7 @@ async function readHttpsBody(url: string, options: HttpAssertionOptions = {}): P
7096
...(options.insecure ? ["-k"] : []),
7197
...curlResolveArgs(url, options.resolveIp),
7298
url
73-
]);
99+
], { timeoutMs: CURL_PROCESS_TIMEOUT_MS });
74100
if (result.exitCode !== 0) {
75101
throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to fetch ${url}`);
76102
}
@@ -123,7 +149,7 @@ export async function waitForHttpStatusResolved(
123149
...(insecure ? ["-k"] : []),
124150
...curlResolveArgs(url, resolveIp),
125151
url
126-
]);
152+
], { timeoutMs: CURL_PROCESS_TIMEOUT_MS });
127153
const status = (result.stdout || "").trim();
128154
lastStatus = status || lastStatus;
129155
lastError = result.stderr.trim() || result.stdout.trim() || lastError;
@@ -135,7 +161,7 @@ export async function waitForHttpStatusResolved(
135161
}
136162
}
137163

138-
await Bun.sleep(5000);
164+
await Bun.sleep(HTTP_POLL_INTERVAL_MS);
139165
}
140166

141167
throw new Error(
@@ -168,8 +194,7 @@ export async function expectHttpBodyContains(
168194
return;
169195
}
170196
} else {
171-
const response = await fetch(url, { redirect: "follow" });
172-
const body = await response.text();
197+
const { response, body } = await fetchTextWithTimeout(url, { redirect: "follow" });
173198
lastStatus = String(response.status);
174199
lastBody = body;
175200
if (body.includes(needle)) {
@@ -180,7 +205,7 @@ export async function expectHttpBodyContains(
180205
lastBody = String(error);
181206
}
182207

183-
await Bun.sleep(5000);
208+
await Bun.sleep(HTTP_POLL_INTERVAL_MS);
184209
}
185210

186211
const bodySnippet = lastBody.replace(/\s+/g, " ").trim().slice(0, 400);
@@ -217,7 +242,7 @@ export async function expectHttpsJson(
217242
lastError = error instanceof Error ? error.message : String(error);
218243
}
219244

220-
await Bun.sleep(5000);
245+
await Bun.sleep(HTTP_POLL_INTERVAL_MS);
221246
}
222247

223248
const bodySnippet = lastBody.replace(/\s+/g, " ").trim().slice(0, 400);

tests/integration/lib/process.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { DEFAULT_LOCAL_PROCESS_ATTEMPTS, isRetryableLocalProcessFailure, run } from "./process";
2+
import { DEFAULT_LOCAL_PROCESS_ATTEMPTS, isRetryableLocalProcessFailure, run, runAllowFailure } from "./process";
33

44
describe("integration process helpers", () => {
55
test("renders stdout and stderr for failed commands", async () => {
@@ -14,4 +14,13 @@ describe("integration process helpers", () => {
1414
expect(isRetryableLocalProcessFailure({ exitCode: 127, stdout: "", stderr: "bash: missing-command: command not found" })).toBeFalse();
1515
expect(isRetryableLocalProcessFailure({ exitCode: 124, stdout: "", stderr: "EBADF: bad file descriptor, epoll_ctl" })).toBeFalse();
1616
});
17+
18+
test("terminates timed-out commands before resolving", async () => {
19+
const startedAt = Date.now();
20+
const result = await runAllowFailure(["bash", "-lc", "trap '' TERM; sleep 10"], { timeoutMs: 100 });
21+
22+
expect(result.exitCode).toBe(124);
23+
expect(result.stderr).toContain("command timed out after 100ms");
24+
expect(Date.now() - startedAt).toBeLessThan(5000);
25+
});
1726
});

tests/integration/lib/process.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ async function runOnceAllowFailure(cmd: string[], options: CommandOptions = {}):
5555
return await new Promise<CommandResult>((resolve) => {
5656
const stdout: Buffer[] = [];
5757
const stderr: Buffer[] = [];
58+
const useProcessGroup = options.timeoutMs !== undefined && process.platform !== "win32";
5859
const proc = spawn(cmd[0] ?? "", cmd.slice(1), {
5960
cwd: options.cwd,
61+
detached: useProcessGroup,
6062
env: options.env ? { ...process.env, ...options.env } : process.env,
6163
stdio: [options.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"]
6264
});
@@ -80,6 +82,18 @@ async function runOnceAllowFailure(cmd: string[], options: CommandOptions = {}):
8082
resolve(result);
8183
};
8284

85+
const killProcess = (signal: NodeJS.Signals): void => {
86+
if (useProcessGroup && proc.pid) {
87+
try {
88+
process.kill(-proc.pid, signal);
89+
return;
90+
} catch {
91+
// Fall back to killing the direct child if process-group signaling is unavailable.
92+
}
93+
}
94+
proc.kill(signal);
95+
};
96+
8397
proc.stdout?.on("data", (chunk: Buffer | string) => {
8498
stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
8599
});
@@ -96,22 +110,24 @@ async function runOnceAllowFailure(cmd: string[], options: CommandOptions = {}):
96110
proc.on("close", (code, signal) => {
97111
const stderrText = Buffer.concat(stderr).toString("utf8");
98112
finish({
99-
exitCode: code ?? (signal ? 128 + signalNumber(signal) : 1),
113+
exitCode: timedOut ? 124 : code ?? (signal ? 128 + signalNumber(signal) : 1),
100114
stdout: Buffer.concat(stdout).toString("utf8"),
101115
stderr: timedOut ? `${stderrText}${stderrText ? "\n" : ""}command timed out after ${options.timeoutMs}ms` : stderrText
102116
});
103117
});
104118

105119
if (options.timeoutMs !== undefined) {
106120
timeout = setTimeout(() => {
121+
if (settled) {
122+
return;
123+
}
107124
timedOut = true;
108-
proc.kill("SIGTERM");
109-
killTimer = setTimeout(() => proc.kill("SIGKILL"), 2000);
110-
finish({
111-
exitCode: 124,
112-
stdout: Buffer.concat(stdout).toString("utf8"),
113-
stderr: `${Buffer.concat(stderr).toString("utf8")}${stderr.length > 0 ? "\n" : ""}command timed out after ${options.timeoutMs}ms`
114-
});
125+
killProcess("SIGTERM");
126+
killTimer = setTimeout(() => {
127+
if (!settled) {
128+
killProcess("SIGKILL");
129+
}
130+
}, 2000);
115131
}, options.timeoutMs);
116132
}
117133

tests/integration/scenarios/common.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,8 @@ export async function verifyManagementSurfaces(
324324
}
325325

326326
/** Verifies the public LXD endpoint serves the real API over trusted TLS and does not expose trusted anonymous access. */
327-
export async function verifyLxdApi(host: ManagedHost): Promise<void> {
327+
export async function verifyLxdApi(host: ManagedHost, context?: IntegrationContext): Promise<void> {
328+
context?.logger.info(`verify ${host.label} LXD API`);
328329
await expectHttpsJson(
329330
`https://${host.domains.lxd}/1.0`,
330331
(body) => {
@@ -351,6 +352,7 @@ export async function verifyLxdApi(host: ManagedHost): Promise<void> {
351352
},
352353
{ timeoutMs: 300000, resolveIp: host.server.ipv4 }
353354
);
355+
context?.logger.info(`verified ${host.label} LXD API`);
354356
}
355357

356358
/** Verifies a real browser login through LXD's public OIDC flow. */
@@ -480,17 +482,19 @@ ${remoteCtl("set idp oidc")} \\
480482
--lxd-oidc-client ${shellArg(fixture.lxdClientId)} \\
481483
--admin-group ${shellArg(fixture.adminGroup)}`
482484
);
483-
await verifyManagementSurfaces(context, host, fixture.adminUser);
484-
await verifyLxdApi(host);
485+
await withStepTimeout(`external OIDC management surfaces for ${host.label}`, 15 * 60 * 1000, () =>
486+
verifyManagementSurfaces(context, host, fixture.adminUser)
487+
);
488+
await withStepTimeout(`external OIDC LXD API for ${host.label}`, 6 * 60 * 1000, () => verifyLxdApi(host, context));
485489
}
486490

487491
/** Reconfigures the primary host back to local ZITADEL and validates its management UIs. */
488492
export async function switchBackToLocalIdp(context: IntegrationContext, host: ManagedHost): Promise<void> {
489493
const ssh = context.ssh(host);
490494
await runDetachedRemoteCommand(ssh, "switch-local-idp", remoteCtl("set idp local"));
491495
const admin = await readLocalZitadelAdmin(ssh);
492-
await verifyManagementSurfaces(context, host, admin);
493-
await verifyLxdApi(host);
496+
await withStepTimeout(`local ZITADEL management surfaces for ${host.label}`, 15 * 60 * 1000, () => verifyManagementSurfaces(context, host, admin));
497+
await withStepTimeout(`local ZITADEL LXD API for ${host.label}`, 6 * 60 * 1000, () => verifyLxdApi(host, context));
494498
}
495499

496500
/** Runs a small route-auth matrix against the currently configured OIDC provider. */

tests/integration/scenarios/smoke.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export async function runSmokeSuite(context: IntegrationContext): Promise<void>
7171

7272
const localAdmin = await readLocalZitadelAdmin(primarySsh);
7373
await verifyManagementSurfaces(context, primary, localAdmin);
74-
await verifyLxdApi(primary);
74+
await verifyLxdApi(primary, context);
7575

7676
const plainRoute = `https://plain-${context.config.slug}.${rootDomain}:8080`;
7777
const authRoute = `https://auth-${context.config.slug}.${rootDomain}:8080@auth`;

0 commit comments

Comments
 (0)