webstack-author/tests/server.test.mjs
2026-08-16 18:58:36 +02:00

295 lines
13 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { get as httpGet } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const serverEntry = path.join(projectDir, "dist", "server.cjs");
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close();
await once(server, "close");
return port;
}
function waitForServer(child) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Testserver startete nicht rechtzeitig.")), 10_000);
const onData = (chunk) => {
if (chunk.toString().includes("Server running")) {
clearTimeout(timeout);
child.stdout.off("data", onData);
resolve();
}
};
child.stdout.on("data", onData);
child.once("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`Testserver endete vorzeitig mit Code ${code}.`));
});
});
}
async function stopServer(child) {
if (child.exitCode !== null) return;
child.kill("SIGTERM");
await once(child, "close");
}
function getWithHost(baseUrl, host) {
return new Promise((resolve, reject) => {
const request = httpGet(baseUrl, { headers: { host } }, (response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => { body += chunk; });
response.on("end", () => resolve({ status: response.statusCode, body }));
});
request.on("error", reject);
});
}
test("production hardening and public routing", async (t) => {
await t.test("production refuses to start without secrets", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "author-missing-secrets-"));
const env = { ...process.env, NODE_ENV: "production", DATA_DIR: dataDir };
delete env.ADMIN_PASSWORD;
delete env.SESSION_SECRET;
const child = spawn(process.execPath, [serverEntry], { cwd: projectDir, env, stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk; });
const [code] = await once(child, "close");
assert.equal(code, 1);
assert.match(stderr, /ADMIN_PASSWORD, SESSION_SECRET/);
await rm(dataDir, { recursive: true, force: true });
});
await t.test("sessions, rate limits, domains and uploads work securely", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "author-server-test-"));
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [serverEntry], {
cwd: projectDir,
env: {
...process.env,
NODE_ENV: "production",
PORT: String(port),
DATA_DIR: dataDir,
ADMIN_PASSWORD: "test-password-12345",
SESSION_SECRET: "test-session-secret-12345678901234567890",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
let response = await fetch(`${baseUrl}/api/admin/session`);
assert.equal(response.status, 401);
response = await fetch(`${baseUrl}/api/admin/login`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl },
body: JSON.stringify({ password: "test-password-12345" }),
});
assert.equal(response.status, 200);
const setCookie = response.headers.get("set-cookie") || "";
assert.match(setCookie, /HttpOnly/i);
assert.match(setCookie, /Secure/i);
assert.match(setCookie, /SameSite=Strict/i);
assert.doesNotMatch(setCookie, /test-password/);
const cookie = setCookie.split(";")[0];
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/author-data`);
assert.equal(response.status, 401);
response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } });
assert.equal(response.status, 200);
const adminData = await response.json();
adminData.erotica.contactEmail = "kontakt@example.test";
adminData.erotica.instagramUrl = "https://instagram.com/example-author";
adminData.erotica.discordUrl = "https://discord.gg/example-author";
adminData.erotica.books[0].seriesName = "Beispiel-Reihe";
adminData.erotica.books[0].seriesNumber = 1;
adminData.erotica.books[0].ebookLink = "https://amazon.example/ebook";
adminData.erotica.books[0].paperbackLink = "https://amazon.example/paperback";
adminData.erotica.customSectionLinks = [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
];
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }),
});
assert.equal(response.status, 200);
const firstSave = await response.json();
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }),
});
assert.equal(response.status, 409);
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({
profileKey: "erotica",
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
expectedRevision: firstSave.revision,
}),
});
assert.equal(response.status, 400);
const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
assert.equal(publicResponse.status, 200);
const publicData = JSON.parse(publicResponse.body);
assert.deepEqual(Object.keys(publicData).sort(), ["legalDocuments", "profile", "theme"]);
assert.equal(publicData.profile.name, "Annie Slone");
assert.equal(publicData.theme, "velvet");
assert.equal(publicData.profile.customDomain, undefined);
assert.equal(publicData.profile.customPath, undefined);
assert.deepEqual(publicData.profile.customSectionLinks, [
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
]);
assert.equal(publicData.profile.contactEmail, "kontakt@example.test");
assert.equal(publicData.profile.discordUrl, "https://discord.gg/example-author");
assert.equal(publicData.profile.books[0].seriesName, "Beispiel-Reihe");
assert.equal(publicData.profile.books[0].seriesNumber, 1);
assert.equal(publicResponse.body.includes("Clara Finch"), false);
assert.equal(publicResponse.body.includes("Renee Heart"), false);
assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
let routedPage = await getWithHost(`${baseUrl}/`, "annieslone.de");
assert.match(routedPage.body, /<title>Annie Slone/);
routedPage = await getWithHost(`${baseUrl}/`, "www.annieslone.de");
assert.match(routedPage.body, /<title>Annie Slone/);
routedPage = await getWithHost(`${baseUrl}/`, "blog.annieslone.de");
assert.match(routedPage.body, /<title>Daniel Hesse/);
const validPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const upload = (fileName, base64Data) => fetch(`${baseUrl}/api/admin/upload-file`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName, base64Data }),
});
response = await upload("cover.png", validPng);
assert.equal(response.status, 201);
const uploaded = await response.json();
assert.match(uploaded.url, /^\/uploads\/[a-z0-9-]+\.png$/);
assert.equal(uploaded.mimeType, "image/png");
response = await upload("fake.png", "data:image/png;base64,SGVsbG8=");
assert.equal(response.status, 415);
response = await upload("wrong.jpg", validPng);
assert.equal(response.status, 415);
const validPdf = `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n%%EOF").toString("base64")}`;
response = await fetch(`${baseUrl}/api/admin/upload-sample`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ fileName: "leseprobe.pdf", base64Data: validPdf }),
});
assert.equal(response.status, 201);
const sample = await response.json();
assert.match(sample.url, /^\/downloads\/[a-z0-9-]+\.pdf$/);
response = await fetch(`${baseUrl}${sample.url}`);
assert.equal(response.status, 200);
assert.match(response.headers.get("content-disposition") || "", /attachment/);
adminData.erotica.books[0].samplePdfUrl = sample.url;
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }),
});
assert.equal(response.status, 200);
const sampleSave = await response.json();
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 409);
response = await fetch(`${baseUrl}${uploaded.url}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
assert.match(response.headers.get("cache-control") || "", /immutable/);
adminData.erotica.avatarUrl = uploaded.url;
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: sampleSave.revision }),
});
assert.equal(response.status, 200);
const referencedSave = await response.json();
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
method: "DELETE",
headers: { origin: baseUrl, cookie },
});
assert.equal(response.status, 409);
adminData.erotica.avatarUrl = "";
adminData.erotica.books[0].samplePdfUrl = "";
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie },
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: referencedSave.revision }),
});
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, {
method: "DELETE",
headers: { origin: baseUrl, cookie },
});
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}${uploaded.url}`);
assert.equal(response.status, 404);
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });
assert.equal(response.status, 401);
const attempts = [];
for (let index = 0; index < 6; index += 1) {
const attempt = await fetch(`${baseUrl}/api/admin/login`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl },
body: JSON.stringify({ password: "wrong-password" }),
});
attempts.push(attempt.status);
}
assert.deepEqual(attempts, [401, 401, 401, 401, 401, 429]);
response = await fetch(`${baseUrl}/`);
assert.equal(response.headers.get("x-frame-options"), "DENY");
assert.ok(response.headers.get("content-security-policy"));
response = await fetch(`${baseUrl}/does-not-exist`);
assert.equal(response.status, 404);
assert.match(response.headers.get("x-robots-tag") || "", /noindex/);
const notFoundBody = await response.text();
assert.match(notFoundBody, /Seite nicht gefunden/);
assert.equal(notFoundBody.includes("Daniel Hesse"), false);
} finally {
await stopServer(child);
await rm(dataDir, { recursive: true, force: true });
}
});
});