feat(login): add ZITADEL_API_AWAITINITIALCONN support (#12032)

This commit is contained in:
Mridang Agarwalla
2026-04-13 09:41:31 +00:00
committed by GitHub
parent 7d231b43d4
commit a28ee63b02
3 changed files with 346 additions and 1 deletions
@@ -0,0 +1,176 @@
import * as path from "path";
import { fileURLToPath } from "node:url";
import type { StartedNetwork } from "testcontainers";
import { GenericContainer, Network, Wait, type StartedTestContainer } from "testcontainers";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`;
const DOCKER_TIMEOUT = 180_000;
/**
* Integration tests for ZITADEL_API_AWAITINITIALCONN support.
*
* When this environment variable is set (e.g. "30s", "5m"), the login
* container polls the ZITADEL API's /debug/ready endpoint before starting
* the Next.js server. This replaces the wait4x init container that was
* previously used in the Helm chart.
*
* The test suite uses a mock Zitadel server that returns 503 on /debug/ready
* for a configurable duration before switching to 200. Three scenarios are
* verified: the API becomes ready within the timeout, the timeout expires
* before the API is ready, and the variable is not set at all.
*/
describe("ZITADEL_API_AWAITINITIALCONN", () => {
beforeAll(async () => {
await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build(LOGIN_IMAGE_TAG);
}, DOCKER_TIMEOUT);
describe("when the API becomes ready before the timeout", () => {
let network: StartedNetwork;
let mockZitadel: StartedTestContainer;
let container: StartedTestContainer;
let appUrl: string;
beforeAll(async () => {
network = await new Network().start();
mockZitadel = await new GenericContainer("node:20-alpine")
.withNetwork(network)
.withNetworkAliases("mock-zitadel")
.withCopyFilesToContainer([
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
])
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "5000" })
.withCommand(["node", "/app/server.js"])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
.start();
container = await new GenericContainer(LOGIN_IMAGE_TAG)
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "http://mock-zitadel:8080",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
ZITADEL_API_AWAITINITIALCONN: "60s",
})
.withStartupTimeout(120_000)
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
}, DOCKER_TIMEOUT);
afterAll(async () => {
await container?.stop().catch(() => {});
await mockZitadel?.stop().catch(() => {});
await network?.stop().catch(() => {});
}, DOCKER_TIMEOUT);
it("starts successfully after the API becomes ready", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
expect(response.status).toBe(200);
});
});
describe("when the API does not become ready before the timeout", () => {
let network: StartedNetwork;
let mockZitadel: StartedTestContainer;
let container: StartedTestContainer;
beforeAll(async () => {
network = await new Network().start();
mockZitadel = await new GenericContainer("node:20-alpine")
.withNetwork(network)
.withNetworkAliases("mock-zitadel")
.withCopyFilesToContainer([
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
])
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "600000" })
.withCommand(["node", "/app/server.js"])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
.start();
container = await new GenericContainer(LOGIN_IMAGE_TAG)
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "http://mock-zitadel:8080",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
ZITADEL_API_AWAITINITIALCONN: "5s",
})
.withStartupTimeout(30_000)
.withWaitStrategy(Wait.forLogMessage(/Timed out after 5s/))
.start();
}, DOCKER_TIMEOUT);
afterAll(async () => {
await container?.stop().catch(() => {});
await mockZitadel?.stop().catch(() => {});
await network?.stop().catch(() => {});
}, DOCKER_TIMEOUT);
it("logs the timeout error before exiting", async () => {
const stream = await container.logs();
const logs = await new Promise<string>((resolve) => {
let output = "";
stream.on("data", (chunk: Buffer) => { output += chunk.toString(); });
stream.on("end", () => resolve(output));
setTimeout(() => { stream.destroy(); resolve(output); }, 5_000);
});
expect(logs).toContain("Timed out after 5s waiting for API readiness");
});
});
describe("when the variable is not set", () => {
let network: StartedNetwork;
let mockZitadel: StartedTestContainer;
let container: StartedTestContainer;
let appUrl: string;
beforeAll(async () => {
network = await new Network().start();
mockZitadel = await new GenericContainer("node:20-alpine")
.withNetwork(network)
.withNetworkAliases("mock-zitadel")
.withCopyFilesToContainer([
{ source: path.join(TEST_DIR, "mock-zitadel-server.js"), target: "/app/server.js" },
])
.withEnvironment({ PORT: "8080", READY_AFTER_MS: "0" })
.withCommand(["node", "/app/server.js"])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forLogMessage("Mock Zitadel server listening"))
.start();
container = await new GenericContainer(LOGIN_IMAGE_TAG)
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "http://mock-zitadel:8080",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
})
.withStartupTimeout(120_000)
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
}, DOCKER_TIMEOUT);
afterAll(async () => {
await container?.stop().catch(() => {});
await mockZitadel?.stop().catch(() => {});
await network?.stop().catch(() => {});
}, DOCKER_TIMEOUT);
it("starts without waiting", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
expect(response.status).toBe(200);
});
});
});
@@ -0,0 +1,66 @@
/**
* Mock Zitadel server for testing ZITADEL_API_AWAITINITIALCONN.
*
* Returns 503 on /debug/ready until READY_AFTER_MS milliseconds have elapsed,
* then switches to 200. All other endpoints return minimal ConnectRPC-compatible
* responses so the login app can start normally once the readiness gate passes.
*/
const http = require("http");
const PORT = process.env.PORT || 8080;
const READY_AFTER_MS = parseInt(process.env.READY_AFTER_MS || "5000", 10);
const startTime = Date.now();
const sendResponse = (res, statusCode, body) => {
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
const server = http.createServer((req, res) => {
const { method, url } = req;
console.log(`[${new Date().toISOString()}] ${method} ${url}`);
if (url === "/debug/ready") {
const elapsed = Date.now() - startTime;
if (elapsed < READY_AFTER_MS) {
console.log(` -> 503 (not ready, ${elapsed}ms / ${READY_AFTER_MS}ms)`);
sendResponse(res, 503, { status: "not ready" });
} else {
console.log(` -> 200 (ready after ${elapsed}ms)`);
sendResponse(res, 200, { status: "ok" });
}
return;
}
if (url.includes("GetLoginSettings") || url.includes("SettingsService")) {
sendResponse(res, 200, { settings: {} });
} else if (url.includes("GetBrandingSettings")) {
sendResponse(res, 200, { settings: {} });
} else if (url.includes("SessionService") || url.includes("Session")) {
sendResponse(res, 200, { session: {} });
} else if (url.includes("UserService") || url.includes("User")) {
sendResponse(res, 200, { user: {} });
} else if (url.includes("OrganizationService") || url.includes("Organization")) {
sendResponse(res, 200, { result: [] });
} else if (url.includes("IdentityProviderService") || url.includes("IdentityProvider")) {
sendResponse(res, 200, { identityProviders: [] });
} else {
sendResponse(res, 200, {});
}
});
server.on("error", (err) => console.error("Server error:", err));
server.listen(PORT, "0.0.0.0", () => {
console.log(`Mock Zitadel server listening on port ${PORT}`);
console.log(`Will become ready after ${READY_AFTER_MS}ms`);
});
process.on("SIGTERM", () => {
console.log("Shutting down mock server...");
server.close(() => {
console.log("Mock server stopped");
process.exit(0);
});
});