diff --git a/apps/login/dockerized/await-api/await-api.integration.test.ts b/apps/login/dockerized/await-api/await-api.integration.test.ts new file mode 100644 index 000000000..d33409530 --- /dev/null +++ b/apps/login/dockerized/await-api/await-api.integration.test.ts @@ -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((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); + }); + }); +}); diff --git a/apps/login/dockerized/await-api/mock-zitadel-server.js b/apps/login/dockerized/await-api/mock-zitadel-server.js new file mode 100644 index 000000000..d20fccd13 --- /dev/null +++ b/apps/login/dockerized/await-api/mock-zitadel-server.js @@ -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); + }); +}); diff --git a/apps/login/scripts/server.mjs b/apps/login/scripts/server.mjs index c1212bf7e..2722ca969 100644 --- a/apps/login/scripts/server.mjs +++ b/apps/login/scripts/server.mjs @@ -1,7 +1,110 @@ import { accessSync, constants, readFileSync } from "node:fs"; -import { createServer } from "node:https"; +import { request as httpRequest } from "node:http"; +import { createServer, request as httpsRequest } from "node:https"; import { createRequire } from "node:module"; +/** + * Parses a Go-style duration string into milliseconds. + * + * This uses the same format as Go's time.ParseDuration (e.g. "30s", "5m", + * "1m30s") to stay consistent with the ZITADEL backend, which uses + * time.Duration for its own ZITADEL_DATABASE_POSTGRES_AWAITINITIALCONN + * setting. Operators configuring both services use the same syntax. + * + * Valid units are "ms", "s", "m", and "h". + * Returns null if the string is empty, zero, or contains invalid syntax. + */ +function parseDuration(raw) { + const trimmed = raw.trim(); + if (!trimmed) return null; + + let totalMs = 0; + let remaining = trimmed; + const unitMap = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }; + + while (remaining.length > 0) { + const match = remaining.match(/^(\d+)(ms|s|m|h)/); + if (!match) return null; + totalMs += parseInt(match[1], 10) * unitMap[match[2]]; + remaining = remaining.slice(match[0].length); + } + + return totalMs > 0 ? totalMs : null; +} + +/** + * Polls an HTTP(S) URL until it returns HTTP 200 or the deadline is reached. + */ +async function pollReady(url, timeoutMs) { + const parsedUrl = new URL(url); + const doRequest = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const ok = await new Promise((resolve) => { + const req = doRequest(parsedUrl, { method: "GET", timeout: 5_000, rejectUnauthorized: false }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); + + if (ok) return true; + + await new Promise((r) => setTimeout(r, 1_000)); + } + + return false; +} + +/** + * Waits for the ZITADEL API to become ready before starting the server. + * + * When ZITADEL_API_AWAITINITIALCONN is set (e.g. "60s", "5m", "120s"), this + * function blocks until GET {ZITADEL_API_URL}/debug/ready returns HTTP 200 + * or the timeout expires. This replaces the wait4x init container that was + * previously used in the Helm chart. + * + * If the timeout expires without a successful response, the process exits + * with code 1. + */ +async function awaitApiReady() { + const raw = process.env.ZITADEL_API_AWAITINITIALCONN; + if (!raw) return; + + const apiUrl = process.env.ZITADEL_API_URL; + if (!apiUrl) { + console.error("ZITADEL_API_AWAITINITIALCONN is set but ZITADEL_API_URL is not configured."); + process.exit(1); + } + + const timeoutMs = parseDuration(raw); + if (timeoutMs === null) { + console.error( + `Invalid ZITADEL_API_AWAITINITIALCONN value "${raw}". Expected a Go-style duration like "30s", "5m", or "1m30s".`, + ); + process.exit(1); + } + + const readyUrl = new URL("/debug/ready", apiUrl).toString(); + console.log(`Waiting up to ${raw} for API to be ready at ${readyUrl} ...`); + + const ok = await pollReady(readyUrl, timeoutMs); + if (ok) { + console.log("API is ready."); + } else { + console.error(`Timed out after ${raw} waiting for API readiness at ${readyUrl}`); + process.exit(1); + } +} + +await awaitApiReady(); + if (process.env.ZITADEL_TLS_ENABLED !== "true") { // The build script must rename the Next.js-generated server.js to next-server.js. // Validate that the file exists before importing to provide a clearer error if the build step fails.