test(news): gate deployment on an isolated real browser publication journey
CI / check (push) Successful in 3m28s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 2m47s

This commit is contained in:
Simo committed 2026-09-13 20:27:04 +02:00
1 parent 60e49c1ec9
commit 7867bf6b72
11 files changed
+980 -2

No files matched your search

+299
View File
@@ -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();
}
});
+39
View File
@@ -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"] } },
],
});
+387
View File
@@ -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);
}
+195
View File
@@ -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')",
);
}