test(news): gate deployment on an isolated real browser publication journey
This commit is contained in:
1 parent
60e49c1ec9
commit
7867bf6b72
11 files changed
+980
-2
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { type BrowserContext, expect, test } from "@playwright/test";
|
||||
import Redis from "ioredis";
|
||||
import mysql, {
|
||||
type ConnectionOptions,
|
||||
type RowDataPacket,
|
||||
} from "mysql2/promise";
|
||||
|
||||
interface Fixture {
|
||||
username: string;
|
||||
userId: number;
|
||||
password: string;
|
||||
database: ConnectionOptions;
|
||||
redis: { host: string; port: number; password: string };
|
||||
}
|
||||
|
||||
async function onlyLocalResources(context: BrowserContext, origin: string) {
|
||||
// External avatars/telemetry are outside this isolated hotel. All application
|
||||
// documents, scripts, forms, API requests and auth responses remain untouched.
|
||||
await context.route("**/*", (route) => {
|
||||
if (new URL(route.request().url()).origin === origin)
|
||||
return route.continue();
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
}
|
||||
|
||||
test("staff signs in, saves a draft, previews it and publishes to anonymous readers", async ({
|
||||
page,
|
||||
context,
|
||||
browser,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
if (!baseURL || !process.env.NEWS_E2E_FIXTURE)
|
||||
throw Error("Disposable fixture is required");
|
||||
const fixture = JSON.parse(
|
||||
await readFile(process.env.NEWS_E2E_FIXTURE, "utf8"),
|
||||
) as Fixture;
|
||||
const database = await mysql.createConnection({
|
||||
...fixture.database,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
const redis = new Redis({
|
||||
...fixture.redis,
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 5_000,
|
||||
});
|
||||
const anonymous = await browser.newContext({
|
||||
baseURL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const reader = await anonymous.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
reader.on("pageerror", (error) => errors.push(error.message));
|
||||
const rows = async (sql: string, params: string[] = []) =>
|
||||
(await database.query<RowDataPacket[]>(sql, params))[0];
|
||||
const title = "Notizia browser: città e novità 🎉";
|
||||
const slug = "browser-news-real";
|
||||
const summary =
|
||||
"Una notizia creata e pubblicata attraverso il pannello reale.";
|
||||
const body = "È una prova reale: caffè, città e 🎉. Salvata dal browser.";
|
||||
let articleId = "";
|
||||
let submittedHtml = "";
|
||||
let draftRevision: string | null = null;
|
||||
try {
|
||||
await onlyLocalResources(context, baseURL);
|
||||
await onlyLocalResources(anonymous, baseURL);
|
||||
await redis.ping();
|
||||
await test.step("real authentication and staff ACL", async () => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.locator('input[autocomplete="username"]')
|
||||
.fill(fixture.username);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.fill(fixture.password);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.press("Enter");
|
||||
await expect(page).toHaveURL(/\/me(?:\?|$)/);
|
||||
const session = await context.request
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
expect(session.user).toMatchObject({
|
||||
id: String(fixture.userId),
|
||||
name: fixture.username,
|
||||
rank: 7,
|
||||
});
|
||||
expect(
|
||||
(await context.cookies()).some(
|
||||
(cookie) =>
|
||||
cookie.name.startsWith("__Secure-authjs.session-token") &&
|
||||
cookie.secure &&
|
||||
cookie.httpOnly,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toEqual([
|
||||
expect.objectContaining({ user_id: fixture.userId }),
|
||||
]);
|
||||
// The editor is below the highest occupied rank: access requires real ACL grants.
|
||||
expect(
|
||||
(await rows("SELECT MAX(rank) AS highest FROM users"))[0].highest,
|
||||
).toBe(9);
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator('input[name="title"]')).toBeVisible();
|
||||
});
|
||||
await test.step("save the draft using the real rich text editor and server action", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('input[name="title"]').fill(title);
|
||||
await form.locator('input[name="slug"]').fill(slug);
|
||||
await form.locator('[name="shortStory"]').fill(summary);
|
||||
await form
|
||||
.locator('input[name="image"]')
|
||||
.fill("/assets/images/EnterHubbly.png");
|
||||
await form.locator('select[name="status"]').selectOption("draft");
|
||||
const editor = form
|
||||
.frameLocator("iframe.tox-edit-area__iframe")
|
||||
.locator('body[contenteditable="true"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill(body);
|
||||
await editor.press("End");
|
||||
await expect(form.locator('textarea[name="fullStory"]')).toHaveValue(
|
||||
/Salvata dal browser/,
|
||||
);
|
||||
submittedHtml = await form
|
||||
.locator('textarea[name="fullStory"]')
|
||||
.inputValue();
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
title,
|
||||
slug,
|
||||
short_story: summary,
|
||||
status: "draft",
|
||||
user_id: fixture.userId,
|
||||
published_at: null,
|
||||
publish_at: null,
|
||||
});
|
||||
expect(articles[0].full_story).toBe(submittedHtml);
|
||||
articleId = String(articles[0].id);
|
||||
expect(await rows("SELECT kind, actor_id FROM cms_operations")).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "news.create",
|
||||
actor_id: fixture.userId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
await test.step("anonymous readers and the shared cache still see no published article", async () => {
|
||||
const response = await reader.goto(`/news/${slug}`);
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
// Next can stream not-found markup with HTTP 200; assert the actual 404 screen.
|
||||
await expect(reader.locator(".error-screen-code")).toHaveText("404");
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toEqual([]);
|
||||
expect(listing.meta.total).toBe(0);
|
||||
draftRevision = await redis.get("cms:news:revision");
|
||||
expect(draftRevision).not.toBeNull();
|
||||
expect(
|
||||
await redis.get(`news:${draftRevision}:article:v2:slug:${slug}`),
|
||||
).toBe("null");
|
||||
});
|
||||
await test.step("preview the persisted draft without publishing it", async () => {
|
||||
await page
|
||||
.locator(`a[href="/admin/articles/${articleId}"]`)
|
||||
.first()
|
||||
.click();
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await expect(form.locator('input[name="title"]')).toHaveValue(title);
|
||||
await expect(form.locator('select[name="status"]')).toHaveValue("draft");
|
||||
await expect(
|
||||
form.frameLocator("iframe.tox-edit-area__iframe").locator("body"),
|
||||
).toContainText(body);
|
||||
await form.getByRole("button", { name: "Preview", exact: true }).click();
|
||||
const preview = page.getByRole("dialog").frameLocator("iframe");
|
||||
await expect(
|
||||
preview.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(preview.locator("body")).toContainText(summary);
|
||||
await expect(preview.locator("body")).toContainText(body);
|
||||
expect(await rows("SELECT status FROM website_articles")).toEqual([
|
||||
{ status: "draft" },
|
||||
]);
|
||||
expect(
|
||||
await rows("SELECT id FROM website_article_revisions"),
|
||||
).toHaveLength(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
await test.step("publish once and persist revision, audit and delivery intent", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('select[name="status"]').selectOption("published");
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
status: "published",
|
||||
user_id: fixture.userId,
|
||||
slug,
|
||||
title,
|
||||
});
|
||||
expect(articles[0].published_at).toBeInstanceOf(Date);
|
||||
const revisions = await rows(
|
||||
"SELECT article_id, user_id, payload FROM website_article_revisions",
|
||||
);
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(String(revisions[0].article_id)).toBe(articleId);
|
||||
expect(Number(revisions[0].user_id)).toBe(fixture.userId);
|
||||
expect(JSON.parse(revisions[0].payload)).toMatchObject({
|
||||
title,
|
||||
status: "draft",
|
||||
});
|
||||
const audit = await rows(
|
||||
"SELECT user_id, `before`, `after` FROM admin_audit_log WHERE target='news'",
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].user_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(audit[0].before).status).toBe("draft");
|
||||
expect(JSON.parse(audit[0].after).status).toBe("published");
|
||||
const operations = await rows(
|
||||
"SELECT kind, actor_id, result_json FROM cms_operations ORDER BY kind",
|
||||
);
|
||||
expect(operations.map((operation) => operation.kind)).toEqual([
|
||||
"news.create",
|
||||
"news.update",
|
||||
]);
|
||||
for (const operation of operations) {
|
||||
expect(operation.actor_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(operation.result_json)).toMatchObject({ ok: true });
|
||||
}
|
||||
const effects = await rows("SELECT topic FROM cms_outbox");
|
||||
expect(effects).toEqual([
|
||||
{ topic: "news.refresh" },
|
||||
{ topic: "news.refresh" },
|
||||
]);
|
||||
expect(await redis.get("cms:news:revision")).not.toBe(draftRevision);
|
||||
});
|
||||
await test.step("anonymous pages and API read the newly published content", async () => {
|
||||
const response = await reader.reload();
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(reader.locator(".article-body")).toContainText(body);
|
||||
await expect(reader.locator(".error-screen-code")).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toHaveLength(1);
|
||||
expect(listing.data[0]).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
shortStory: summary,
|
||||
});
|
||||
expect(listing.meta.total).toBe(1);
|
||||
const revision = await redis.get("cms:news:revision");
|
||||
const cached = await redis.get(
|
||||
`news:${revision}:article:v2:slug:${slug}`,
|
||||
);
|
||||
expect(JSON.parse(cached ?? "null")).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
});
|
||||
expect(
|
||||
(await anonymous.cookies()).some((cookie) =>
|
||||
cookie.name.includes("session-token"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
} finally {
|
||||
if (errors.length)
|
||||
await testInfo.attach("browser-errors", {
|
||||
body: JSON.stringify(errors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await anonymous.close();
|
||||
await database.end();
|
||||
redis.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
if (!process.env.NEWS_E2E_FIXTURE || !process.env.NEWS_E2E_BASE_URL)
|
||||
throw Error(
|
||||
"Run this suite with pnpm test:news:real; it requires disposable services.",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "news.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
outputDir: "../../test-results/news-real",
|
||||
reporter: [
|
||||
["list"],
|
||||
[
|
||||
"html",
|
||||
{ outputFolder: "../../playwright-report/news-real", open: "never" },
|
||||
],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.NEWS_E2E_BASE_URL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
executablePath: process.env.UI_TEST_BROWSER_PATH || undefined,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium-real-news", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { is, SQL } from "drizzle-orm";
|
||||
import {
|
||||
getTableConfig,
|
||||
MySqlDialect,
|
||||
type MySqlTable,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { bcrypt } from "hash-wasm";
|
||||
import mysql, { type Connection } from "mysql2/promise";
|
||||
import { splitSqlStatements } from "../../scripts/sql-statements";
|
||||
import * as schema from "../../src/db/schema";
|
||||
|
||||
export const STAFF_ID = 7;
|
||||
export const STAFF_USERNAME = "NewsEditor";
|
||||
|
||||
// Use the actual ORM definitions for the emulator tables this browser journey reads.
|
||||
// CMS recovery/outbox tables below come from the shipped migrations themselves.
|
||||
const tables: MySqlTable[] = [
|
||||
schema.User,
|
||||
schema.Ban,
|
||||
schema.WebsiteSetting,
|
||||
schema.WebsiteLanguages,
|
||||
schema.AclRole,
|
||||
schema.AclPermission,
|
||||
schema.AclModelRole,
|
||||
schema.AclModelPermission,
|
||||
schema.WebsiteArticles,
|
||||
schema.WebsiteArticleComments,
|
||||
schema.WebsiteArticleReactions,
|
||||
schema.WebsiteLoginLogs,
|
||||
schema.AdminAuditLog,
|
||||
schema.StaffActivities,
|
||||
schema.WebsiteIpBlacklist,
|
||||
schema.WebsiteIpWhitelist,
|
||||
schema.AlertLogs,
|
||||
schema.ThemeScope,
|
||||
schema.ThemeScopeValue,
|
||||
schema.UsersCurrency,
|
||||
schema.MessengerFriendrequests,
|
||||
schema.MessengerFriendships,
|
||||
schema.MessengerOffline,
|
||||
schema.Rooms,
|
||||
schema.UsersBadges,
|
||||
schema.UsersSettings,
|
||||
schema.UserReferrals,
|
||||
schema.WebsiteEvent,
|
||||
schema.WebsiteEventType,
|
||||
];
|
||||
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
|
||||
|
||||
export function fixtureStatements(): string[] {
|
||||
const dialect = new MySqlDialect();
|
||||
return tables.map((table) => {
|
||||
const config = getTableConfig(table);
|
||||
// Fail on new constraints instead of silently reducing the fixture's integrity.
|
||||
if (
|
||||
config.foreignKeys.length ||
|
||||
config.checks.length ||
|
||||
config.uniqueConstraints.length
|
||||
)
|
||||
throw Error(`Fixture requires explicit constraints for ${config.name}`);
|
||||
const columns = config.columns.map((column) => {
|
||||
let ddl = `${identifier(column.name)} ${column.getSQLType()}`;
|
||||
ddl += column.notNull ? " NOT NULL" : " NULL";
|
||||
if ("autoIncrement" in column && column.autoIncrement)
|
||||
ddl += " AUTO_INCREMENT";
|
||||
if (column.primary) ddl += " PRIMARY KEY";
|
||||
if (column.isUnique) ddl += " UNIQUE";
|
||||
if (column.default !== undefined) {
|
||||
if (is(column.default, SQL)) {
|
||||
const query = dialect.sqlToQuery(column.default);
|
||||
if (query.params.length)
|
||||
throw Error(
|
||||
`Fixture requires parameterized default for ${config.name}.${column.name}`,
|
||||
);
|
||||
ddl += ` DEFAULT ${query.sql}`;
|
||||
} else if (
|
||||
typeof column.default === "string" ||
|
||||
typeof column.default === "number" ||
|
||||
typeof column.default === "boolean" ||
|
||||
column.default === null
|
||||
)
|
||||
ddl += ` DEFAULT ${mysql.escape(column.default)}`;
|
||||
else
|
||||
throw Error(
|
||||
`Fixture requires explicit default for ${config.name}.${column.name}`,
|
||||
);
|
||||
} else if (!column.notNull) ddl += " DEFAULT NULL";
|
||||
return ddl;
|
||||
});
|
||||
for (const key of config.primaryKeys)
|
||||
columns.push(
|
||||
`PRIMARY KEY (${key.columns.map((column) => identifier(column.name)).join(", ")})`,
|
||||
);
|
||||
for (const index of config.indexes) {
|
||||
if (!index.config.unique) continue;
|
||||
const names = index.config.columns.map((column) => {
|
||||
if (is(column, SQL))
|
||||
throw Error(`Fixture requires expression index for ${config.name}`);
|
||||
return identifier(column.name);
|
||||
});
|
||||
columns.push(
|
||||
`UNIQUE KEY ${identifier(index.config.name)} (${names.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return `CREATE TABLE ${identifier(config.name)} (${columns.join(",\n")}) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedDatabase(
|
||||
connection: Connection,
|
||||
staffPassword: string,
|
||||
) {
|
||||
for (const statement of fixtureStatements())
|
||||
await connection.query(statement);
|
||||
// Rank metadata belongs to the emulator and is queried as raw SQL by authorization.
|
||||
await connection.query(
|
||||
"CREATE TABLE permission_ranks (id INT PRIMARY KEY, rank_name VARCHAR(255) NOT NULL) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO permission_ranks (id, rank_name) VALUES (1, 'Member'), (7, 'Editor'), (9, 'Owner')",
|
||||
);
|
||||
for (const migration of [
|
||||
"0026_article_editor_recovery.sql",
|
||||
"0031_operations_outbox.sql",
|
||||
]) {
|
||||
const contents = await readFile(
|
||||
new URL(`../../drizzle/migrations/${migration}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const statement of splitSqlStatements(contents))
|
||||
await connection.query(statement);
|
||||
}
|
||||
const password = await bcrypt({
|
||||
password: staffPassword,
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
const ownerPassword = await bcrypt({
|
||||
password: randomBytes(32).toString("hex"),
|
||||
salt: randomBytes(16),
|
||||
costFactor: 12,
|
||||
outputType: "encoded",
|
||||
});
|
||||
await connection.query(
|
||||
"INSERT INTO users (id, username, password, rank, account_created, ip_register, ip_current, mail, mail_verified) VALUES (?, ?, ?, 7, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1'), (9, 'FixtureOwner', ?, 9, ?, '127.0.0.1', '127.0.0.1', '[email protected]', '1')",
|
||||
[
|
||||
STAFF_ID,
|
||||
STAFF_USERNAME,
|
||||
password,
|
||||
Math.floor(Date.now() / 1000),
|
||||
ownerPassword,
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_roles (id, slug, title) VALUES (1, 'news_editor', 'News editor')",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_roles (model_type, model_id, role_id) VALUES ('User', ?, 1)",
|
||||
[STAFF_ID],
|
||||
);
|
||||
for (const [index, slug] of [
|
||||
"admin.dashboard",
|
||||
"admin.news.view",
|
||||
"admin.news.edit",
|
||||
].entries()) {
|
||||
await connection.query(
|
||||
"INSERT INTO acl_permissions (id, slug, title) VALUES (?, ?, ?)",
|
||||
[index + 1, slug, slug],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO acl_model_permissions (model_type, model_id, permission_id) VALUES ('Role', 1, ?)",
|
||||
[index + 1],
|
||||
);
|
||||
}
|
||||
for (const [key, value] of Object.entries({
|
||||
hotel_name: "News browser fixture",
|
||||
captcha_provider: "none",
|
||||
require_email_verification: "1",
|
||||
force_staff_2fa: "0",
|
||||
maintenance_enabled: "0",
|
||||
abuse_guard_enabled: "0",
|
||||
radio_enabled: "0",
|
||||
}))
|
||||
await connection.query(
|
||||
"INSERT INTO website_settings (`key`, value) VALUES (?, ?)",
|
||||
[key, value],
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO website_languages (country_code, language) VALUES ('en', 'English')",
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user