388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { execFile, spawn } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { once } from "node:events";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { request as httpRequest } from "node:http";
|
|
import { createServer, type Server } from "node:https";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import mysql, { type Connection } from "mysql2/promise";
|
|
import {
|
|
GenericContainer,
|
|
Network,
|
|
type StartedNetwork,
|
|
type StartedTestContainer,
|
|
Wait,
|
|
} from "testcontainers";
|
|
import { STAFF_ID, STAFF_USERNAME, seedDatabase } from "./schema";
|
|
|
|
const root = fileURLToPath(new URL("../../", import.meta.url));
|
|
const execute = promisify(execFile);
|
|
const image = process.env.NEWS_E2E_IMAGE;
|
|
const release = process.env.NEWS_E2E_RELEASE;
|
|
if (!image)
|
|
throw Error(
|
|
"NEWS_E2E_IMAGE is required: build the candidate image before running this test. Docker is mandatory.",
|
|
);
|
|
if (!/^epicnext-cms:[a-zA-Z0-9_.-]+$/.test(image))
|
|
throw Error("NEWS_E2E_IMAGE must name a local epicnext-cms candidate tag.");
|
|
if (release && !/^[0-9a-f]{40}$/.test(release))
|
|
throw Error("NEWS_E2E_RELEASE must be a full commit SHA.");
|
|
|
|
// Never propagate checkout .env, NODE_OPTIONS, installation DB URLs or service tokens.
|
|
const inherited = (names: string[]): NodeJS.ProcessEnv => ({
|
|
NODE_ENV: "test",
|
|
...Object.fromEntries(
|
|
names.flatMap((name) =>
|
|
process.env[name] === undefined ? [] : [[name, process.env[name]]],
|
|
),
|
|
),
|
|
});
|
|
const hostEnv = inherited([
|
|
"PATH",
|
|
"Path",
|
|
"SystemRoot",
|
|
"ComSpec",
|
|
"TEMP",
|
|
"TMP",
|
|
"TMPDIR",
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"LOCALAPPDATA",
|
|
]);
|
|
const dockerEnv = {
|
|
...hostEnv,
|
|
...inherited([
|
|
"DOCKER_HOST",
|
|
"DOCKER_CONTEXT",
|
|
"DOCKER_CONFIG",
|
|
"DOCKER_CERT_PATH",
|
|
"DOCKER_TLS_VERIFY",
|
|
]),
|
|
};
|
|
const secrets = Array.from({ length: 4 }, () =>
|
|
randomBytes(32).toString("hex"),
|
|
);
|
|
const [databasePassword, redisPassword, staffPassword, authSecret] = secrets;
|
|
const redact = (text: string) =>
|
|
secrets.reduce(
|
|
(value, secret) => value.replaceAll(secret, "[fixture secret]"),
|
|
text,
|
|
);
|
|
const abort = new AbortController();
|
|
const onSignal = () => abort.abort();
|
|
process.once("SIGINT", onSignal);
|
|
process.once("SIGTERM", onSignal);
|
|
let network: StartedNetwork | undefined;
|
|
let maria: StartedTestContainer | undefined;
|
|
let redis: StartedTestContainer | undefined;
|
|
let app: StartedTestContainer | undefined;
|
|
let database: Connection | undefined;
|
|
let proxy: Server | undefined;
|
|
let temporary: string | undefined;
|
|
let logs = "";
|
|
|
|
try {
|
|
// Resolve the existing image before Testcontainers; starting by immutable ID cannot pull a tag.
|
|
const inspected = await execute(
|
|
"docker",
|
|
["image", "inspect", image, "--format", "{{json .}}"],
|
|
{ env: dockerEnv, timeout: 15_000 },
|
|
);
|
|
const candidate = JSON.parse(inspected.stdout) as {
|
|
Id: string;
|
|
Config: { Labels?: Record<string, string> };
|
|
};
|
|
if (!/^sha256:[0-9a-f]{64}$/.test(candidate.Id))
|
|
throw Error("Candidate image identity is invalid");
|
|
if (
|
|
release &&
|
|
candidate.Config.Labels?.["org.opencontainers.image.revision"] !== release
|
|
)
|
|
throw Error("Candidate image revision does not match NEWS_E2E_RELEASE");
|
|
abort.signal.throwIfAborted();
|
|
temporary = await mkdtemp(join(tmpdir(), "epicnext-news-e2e-"));
|
|
// Fresh local-only TLS material never enters the repository or build artifacts.
|
|
await execute(
|
|
"openssl",
|
|
[
|
|
"req",
|
|
"-x509",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-nodes",
|
|
"-keyout",
|
|
join(temporary, "localhost.key"),
|
|
"-out",
|
|
join(temporary, "localhost.crt"),
|
|
"-days",
|
|
"1",
|
|
"-subj",
|
|
"/CN=localhost",
|
|
"-addext",
|
|
"subjectAltName=IP:127.0.0.1,DNS:localhost",
|
|
],
|
|
{ cwd: temporary, env: hostEnv, timeout: 30_000, signal: abort.signal },
|
|
);
|
|
console.log("News browser gate: starting isolated MariaDB and Redis");
|
|
network = await new Network().start();
|
|
const services = await Promise.allSettled([
|
|
new GenericContainer("mariadb:11.4.5")
|
|
.withNetwork(network)
|
|
.withNetworkAliases("news-db")
|
|
.withEnvironment({
|
|
MARIADB_ROOT_PASSWORD: randomBytes(32).toString("hex"),
|
|
MARIADB_DATABASE: "news_e2e",
|
|
MARIADB_USER: "news_e2e",
|
|
MARIADB_PASSWORD: databasePassword,
|
|
})
|
|
.withExposedPorts(3306)
|
|
.withHealthCheck({
|
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
|
interval: 1000,
|
|
timeout: 5000,
|
|
retries: 60,
|
|
startPeriod: 1000,
|
|
})
|
|
.withWaitStrategy(Wait.forHealthCheck())
|
|
.withStartupTimeout(120_000)
|
|
.start()
|
|
.then((container) => {
|
|
maria = container;
|
|
}),
|
|
new GenericContainer("redis:7.4.2-alpine")
|
|
.withNetwork(network)
|
|
.withNetworkAliases("news-redis")
|
|
.withCommand(["redis-server", "--requirepass", redisPassword])
|
|
.withExposedPorts(6379)
|
|
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
|
|
.withStartupTimeout(60_000)
|
|
.start()
|
|
.then((container) => {
|
|
redis = container;
|
|
}),
|
|
]);
|
|
for (const service of services)
|
|
if (service.status === "rejected") throw service.reason;
|
|
if (!maria || !redis) throw Error("Disposable services did not start");
|
|
abort.signal.throwIfAborted();
|
|
database = await mysql.createConnection({
|
|
host: maria.getHost(),
|
|
port: maria.getMappedPort(3306),
|
|
user: "news_e2e",
|
|
password: databasePassword,
|
|
database: "news_e2e",
|
|
charset: "utf8mb4",
|
|
timezone: "Z",
|
|
supportBigNumbers: true,
|
|
bigNumberStrings: true,
|
|
});
|
|
await seedDatabase(database, staffPassword);
|
|
await database.end();
|
|
database = undefined;
|
|
|
|
let upstream: URL | undefined;
|
|
let origin = "";
|
|
proxy = createServer(
|
|
{
|
|
cert: await readFile(join(temporary, "localhost.crt")),
|
|
key: await readFile(join(temporary, "localhost.key")),
|
|
},
|
|
(request, response) => {
|
|
if (!upstream || request.headers.host !== new URL(origin).host) {
|
|
response.writeHead(503);
|
|
response.end();
|
|
return;
|
|
}
|
|
// A real local TLS edge, with the same forwarded-host/proto contract as deployment.
|
|
const headers = { ...request.headers };
|
|
delete headers["cf-connecting-ip"];
|
|
delete headers["x-real-client-ip"];
|
|
headers["x-forwarded-for"] = "127.0.0.1";
|
|
headers["x-real-ip"] = "127.0.0.1";
|
|
headers["x-forwarded-host"] = new URL(origin).host;
|
|
headers["x-forwarded-proto"] = "https";
|
|
const forwarded = httpRequest(
|
|
{
|
|
hostname: upstream.hostname,
|
|
port: upstream.port,
|
|
path: request.url,
|
|
method: request.method,
|
|
headers,
|
|
},
|
|
(received) => {
|
|
response.writeHead(received.statusCode ?? 502, received.headers);
|
|
received.pipe(response);
|
|
},
|
|
);
|
|
forwarded.on("error", () => {
|
|
if (!response.headersSent) response.writeHead(502);
|
|
response.end();
|
|
});
|
|
request.on("aborted", () => forwarded.destroy());
|
|
request.pipe(forwarded);
|
|
},
|
|
);
|
|
proxy.listen(0, "127.0.0.1");
|
|
await once(proxy, "listening");
|
|
const address = proxy.address();
|
|
if (!address || typeof address === "string")
|
|
throw Error("Local TLS listener is unavailable");
|
|
origin = `https://127.0.0.1:${address.port}`;
|
|
console.log("News browser gate: starting the production candidate image");
|
|
app = await new GenericContainer(candidate.Id.slice("sha256:".length))
|
|
.withNetwork(network)
|
|
.withEnvironment({
|
|
NODE_ENV: "production",
|
|
HOSTNAME: "0.0.0.0",
|
|
PORT: "3002",
|
|
DATABASE_URL: `mysql://news_e2e:${databasePassword}@news-db:3306/news_e2e`,
|
|
REDIS_URL: `redis://:${redisPassword}@news-redis:6379/0`,
|
|
HOTEL_NAME: "News browser fixture",
|
|
AUTH_SECRET: authSecret,
|
|
APP_URL: origin,
|
|
NEXT_PUBLIC_APP_URL: origin,
|
|
AUTH_URL: origin,
|
|
RCON_HOST: "127.0.0.1",
|
|
RCON_PORT: "9",
|
|
RCON_TIMEOUT_MS: "100",
|
|
RCON_MAX_RETRIES: "1",
|
|
IMAGING_UPSTREAM_URL: "http://127.0.0.1:9",
|
|
LOG_LEVEL: "warn",
|
|
})
|
|
.withExposedPorts(3002)
|
|
.withWaitStrategy(Wait.forHttp("/api/health", 3002).forStatusCode(200))
|
|
.withStartupTimeout(120_000)
|
|
.withLogConsumer((stream) =>
|
|
stream.on("data", (chunk: Buffer) => {
|
|
logs = (logs + chunk.toString()).slice(-2_000_000);
|
|
}),
|
|
)
|
|
.start();
|
|
upstream = new URL(`http://${app.getHost()}:${app.getMappedPort(3002)}`);
|
|
abort.signal.throwIfAborted();
|
|
const health = (await fetch(new URL("/api/health", upstream), {
|
|
signal: AbortSignal.timeout(10_000),
|
|
}).then((response) => response.json())) as {
|
|
status?: string;
|
|
database?: boolean;
|
|
redis?: boolean;
|
|
};
|
|
if (
|
|
health.status !== "ok" ||
|
|
health.database !== true ||
|
|
health.redis !== true
|
|
)
|
|
throw Error("Candidate health does not confirm both disposable services");
|
|
const fixturePath = join(temporary, "fixture.json");
|
|
await writeFile(
|
|
fixturePath,
|
|
JSON.stringify({
|
|
username: STAFF_USERNAME,
|
|
userId: STAFF_ID,
|
|
password: staffPassword,
|
|
database: {
|
|
host: maria.getHost(),
|
|
port: maria.getMappedPort(3306),
|
|
user: "news_e2e",
|
|
password: databasePassword,
|
|
database: "news_e2e",
|
|
},
|
|
redis: {
|
|
host: redis.getHost(),
|
|
port: redis.getMappedPort(6379),
|
|
password: redisPassword,
|
|
},
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
console.log(
|
|
"News browser gate: login, draft, preview, publish and anonymous read",
|
|
);
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
resolve(root, "node_modules/@playwright/test/cli.js"),
|
|
"test",
|
|
"--config",
|
|
"e2e/news-real/playwright.config.ts",
|
|
],
|
|
{
|
|
cwd: root,
|
|
env: {
|
|
...hostEnv,
|
|
...inherited([
|
|
"DISPLAY",
|
|
"XAUTHORITY",
|
|
"PLAYWRIGHT_BROWSERS_PATH",
|
|
"UI_TEST_BROWSER_PATH",
|
|
"CI",
|
|
]),
|
|
NEWS_E2E_FIXTURE: fixturePath,
|
|
NEWS_E2E_BASE_URL: origin,
|
|
},
|
|
stdio: "inherit",
|
|
signal: abort.signal,
|
|
},
|
|
);
|
|
const [code] = (await once(child, "exit")) as [number | null];
|
|
if (code !== 0)
|
|
throw Error(`Real news browser suite failed (exit ${code ?? "signal"})`);
|
|
console.log("News browser gate passed");
|
|
} catch (error) {
|
|
console.error(
|
|
redact(
|
|
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
),
|
|
);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
// Stop only objects created by this run. Testcontainers' resource reaper also owns them.
|
|
const cleanups: Array<[string, () => Promise<unknown>]> = [
|
|
[
|
|
"TLS proxy",
|
|
async () => {
|
|
proxy?.closeAllConnections();
|
|
if (proxy)
|
|
await new Promise<void>((done) => proxy?.close(() => done()));
|
|
},
|
|
],
|
|
["candidate", async () => app?.stop({ timeout: 10_000 })],
|
|
["database connection", async () => database?.end()],
|
|
["Redis", async () => redis?.stop()],
|
|
["MariaDB", async () => maria?.stop()],
|
|
["network", async () => network?.stop()],
|
|
[
|
|
"temporary credentials",
|
|
async () => {
|
|
if (!temporary) return;
|
|
if (
|
|
dirname(resolve(temporary)) !== resolve(tmpdir()) ||
|
|
!basename(temporary).startsWith("epicnext-news-e2e-")
|
|
)
|
|
throw Error(
|
|
"Temporary credentials path escaped the fixture directory",
|
|
);
|
|
await rm(temporary, { recursive: true, force: true });
|
|
},
|
|
],
|
|
];
|
|
for (const [name, cleanup] of cleanups) {
|
|
try {
|
|
await cleanup();
|
|
} catch (error) {
|
|
console.error(`Cleanup failed for ${name}: ${redact(String(error))}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
await mkdir(resolve(root, "test-results/news-real"), { recursive: true });
|
|
await writeFile(
|
|
resolve(root, "test-results/news-real/server.log"),
|
|
redact(logs),
|
|
);
|
|
process.removeListener("SIGINT", onSignal);
|
|
process.removeListener("SIGTERM", onSignal);
|
|
}
|