Runner: Zeiten-Skript beim Veröffentlichen einsetzen
injectIntoHtml nimmt eine Marke je Tag (geprüft gegen data-wk-[a-z]+), injectZeiten setzt den data-wk-zeiten-Tag ohne Datenschutzseite ein, validateJob lässt nur den siteKey durch, ZEITEN_PUBLIC_BASE in der Konfiguration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a198c472bd
commit
a2d11ad6c4
5 files changed
+297
-10
No files matched your search
@@ -20,6 +20,11 @@ export function scriptTag(analytics, baseUrl) {
|
|||||||
return `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
|
return `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Öffnungszeiten live: siteKey und Basis-URL werden vor dem Aufruf per Regex geprüft (injectZeiten), daher ohne Maskierung sicher.
|
||||||
|
export function zeitenScriptTag(zeiten, baseUrl) {
|
||||||
|
return `<script data-wk-zeiten src="${baseUrl}zeiten.js" data-site="${zeiten.siteKey}" defer></script>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
|
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
|
||||||
function findSpans(s) {
|
function findSpans(s) {
|
||||||
const spans = [];
|
const spans = [];
|
||||||
@@ -45,11 +50,16 @@ function findSpans(s) {
|
|||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RE_MARKER = /^data-wk-[a-z]+$/;
|
||||||
|
|
||||||
// Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben.
|
// Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben.
|
||||||
export function injectIntoHtml(buf, tag) {
|
// Die Marke (Attribut des Tags, z. B. data-wk-zeiten) erkennt einen früher eingesetzten Tag; sie wird geprüft, weil sie in einen regulären Ausdruck eingeht.
|
||||||
|
export function injectIntoHtml(buf, tag, marker = 'data-wk-analytics') {
|
||||||
|
if (typeof marker !== 'string' || !RE_MARKER.test(marker)) throw new Error('Ungültiger Marker');
|
||||||
|
const reOld = new RegExp(`\\b${marker}\\b`, 'i');
|
||||||
let s = buf.toString('latin1');
|
let s = buf.toString('latin1');
|
||||||
const spans = findSpans(s);
|
const spans = findSpans(s);
|
||||||
const olds = spans.filter((x) => x.kind === 'script' && /\bdata-wk-analytics\b/i.test(s.slice(x.start, s.indexOf('>', x.start) + 1)));
|
const olds = spans.filter((x) => x.kind === 'script' && reOld.test(s.slice(x.start, s.indexOf('>', x.start) + 1)));
|
||||||
if (olds.length) {
|
if (olds.length) {
|
||||||
for (let i = olds.length - 1; i >= 0; i--) s = s.slice(0, olds[i].start) + (i === 0 ? tag : '') + s.slice(olds[i].end);
|
for (let i = olds.length - 1; i >= 0; i--) s = s.slice(0, olds[i].start) + (i === 0 ? tag : '') + s.slice(olds[i].end);
|
||||||
return Buffer.from(s, 'latin1');
|
return Buffer.from(s, 'latin1');
|
||||||
@@ -74,6 +84,18 @@ async function* walk(fs, dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setzt den Tag in alle HTML-Dateien unterhalb von dir ein (Dateien über 5 MB bleiben unberührt) und liefert die Anzahl.
|
||||||
|
async function injectTagIntoDir(fs, dir, tag, marker, label, onLine) {
|
||||||
|
let n = 0;
|
||||||
|
for await (const file of walk(fs, dir)) {
|
||||||
|
const rel = file.slice(dir.length + 1);
|
||||||
|
if ((await fs.stat(file)).size > MAX_HTML_BYTES) { onLine?.(`${label}: ${rel} übersprungen (größer als 5 MB)`); continue; }
|
||||||
|
await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag, marker));
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = nodeFs } = {}) {
|
export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = nodeFs } = {}) {
|
||||||
if (!analytics) return;
|
if (!analytics) return;
|
||||||
if (!RE_SITE_KEY.test(analytics.siteKey || '')) throw new Error('Analyse: ungültiger siteKey');
|
if (!RE_SITE_KEY.test(analytics.siteKey || '')) throw new Error('Analyse: ungültiger siteKey');
|
||||||
@@ -86,12 +108,15 @@ export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = no
|
|||||||
const page = withAddr.replace(/\{\{(company|address|email)\}\}/g, (_, k) => esc(p[k] ?? ''));
|
const page = withAddr.replace(/\{\{(company|address|email)\}\}/g, (_, k) => esc(p[k] ?? ''));
|
||||||
await fs.writeFile(join(dir, PAGE), page, 'utf8');
|
await fs.writeFile(join(dir, PAGE), page, 'utf8');
|
||||||
await fs.chmod(join(dir, PAGE), 0o644);
|
await fs.chmod(join(dir, PAGE), 0o644);
|
||||||
let n = 0;
|
const n = await injectTagIntoDir(fs, dir, tag, 'data-wk-analytics', 'Analyse', onLine);
|
||||||
for await (const file of walk(fs, dir)) {
|
|
||||||
const rel = file.slice(dir.length + 1);
|
|
||||||
if ((await fs.stat(file)).size > MAX_HTML_BYTES) { onLine?.(`Analyse: ${rel} übersprungen (größer als 5 MB)`); continue; }
|
|
||||||
await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag));
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
onLine?.(`Analyse-Skript in ${n} HTML-Datei(en) eingesetzt`);
|
onLine?.(`Analyse-Skript in ${n} HTML-Datei(en) eingesetzt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Öffnungszeiten live: nur der Skript-Tag, keine Datenschutzseite.
|
||||||
|
export async function injectZeiten(dir, zeiten, { baseUrl, onLine, fs = nodeFs } = {}) {
|
||||||
|
if (!zeiten) return;
|
||||||
|
if (typeof zeiten.siteKey !== 'string' || !RE_SITE_KEY.test(zeiten.siteKey)) throw new Error('Zeiten: ungültiger siteKey');
|
||||||
|
if (!isValidBase(baseUrl)) throw new Error('Zeiten: ungültige öffentliche Basis-URL');
|
||||||
|
const n = await injectTagIntoDir(fs, dir, zeitenScriptTag(zeiten, baseUrl), 'data-wk-zeiten', 'Zeiten', onLine);
|
||||||
|
onLine?.(`Zeiten-Skript in ${n} HTML-Datei(en) eingesetzt`);
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
export function loadConfig(env = process.env) {
|
export function loadConfig(env = process.env) {
|
||||||
const analyticsPublicBase = env.ANALYTICS_PUBLIC_BASE || 'https://project.webklar.com/a/';
|
const analyticsPublicBase = env.ANALYTICS_PUBLIC_BASE || 'https://project.webklar.com/a/';
|
||||||
if (!/^https:\/\/[^\s"'<>]+\/$/.test(analyticsPublicBase)) throw new Error('ANALYTICS_PUBLIC_BASE muss mit https:// beginnen und mit / enden');
|
if (!/^https:\/\/[^\s"'<>]+\/$/.test(analyticsPublicBase)) throw new Error('ANALYTICS_PUBLIC_BASE muss mit https:// beginnen und mit / enden');
|
||||||
|
const zeitenPublicBase = env.ZEITEN_PUBLIC_BASE || 'https://project.webklar.com/embed/';
|
||||||
|
if (!/^https:\/\/[^\s"'<>]+\/$/.test(zeitenPublicBase)) throw new Error('ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden');
|
||||||
return {
|
return {
|
||||||
analyticsPublicBase,
|
analyticsPublicBase,
|
||||||
|
zeitenPublicBase,
|
||||||
token: env.PREVIEW_RUNNER_TOKEN || '',
|
token: env.PREVIEW_RUNNER_TOKEN || '',
|
||||||
giteaUrl: env.GITEA_URL || '',
|
giteaUrl: env.GITEA_URL || '',
|
||||||
giteaToken: env.GITEA_TOKEN || '',
|
giteaToken: env.GITEA_TOKEN || '',
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,7 @@ import { join, dirname, sep } from 'node:path';
|
|||||||
import { buildRunArgs } from './docker.js';
|
import { buildRunArgs } from './docker.js';
|
||||||
import { detectFromDir } from './detect.js';
|
import { detectFromDir } from './detect.js';
|
||||||
import { readMemAvailable } from './server.js';
|
import { readMemAvailable } from './server.js';
|
||||||
import { injectAnalytics } from './analytics-inject.js';
|
import { injectAnalytics, injectZeiten } from './analytics-inject.js';
|
||||||
|
|
||||||
const LOG_LINES = 300;
|
const LOG_LINES = 300;
|
||||||
const MAX_LINE = 2000;
|
const MAX_LINE = 2000;
|
||||||
@@ -257,6 +257,8 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem
|
|||||||
await ensureIndexHtml(fs, fresh, job.displayName || job.sub, onLine);
|
await ensureIndexHtml(fs, fresh, job.displayName || job.sub, onLine);
|
||||||
// Analyse (optional): auf dem .new-Ordner vor dem Tausch; Fehler brechen die Veröffentlichung ab, die alte Preview bleibt (V4).
|
// Analyse (optional): auf dem .new-Ordner vor dem Tausch; Fehler brechen die Veröffentlichung ab, die alte Preview bleibt (V4).
|
||||||
if (job.analytics) await injectAnalytics(fresh, job.analytics, { baseUrl: config.analyticsPublicBase, onLine, fs });
|
if (job.analytics) await injectAnalytics(fresh, job.analytics, { baseUrl: config.analyticsPublicBase, onLine, fs });
|
||||||
|
// Öffnungszeiten live: gleiche Runde, gleiche Fehlerfolge (Job failed, alte Preview bleibt). Nie im Dev-Pfad.
|
||||||
|
if (job.zeiten) await injectZeiten(fresh, job.zeiten, { baseUrl: config.zeitenPublicBase, onLine, fs });
|
||||||
try { await fs.rename(target, old); } catch (e) { if (e.code !== 'ENOENT') throw e; hadOld = false; }
|
try { await fs.rename(target, old); } catch (e) { if (e.code !== 'ENOENT') throw e; hadOld = false; }
|
||||||
try { await fs.rename(fresh, target); } catch (e) {
|
try { await fs.rename(fresh, target); } catch (e) {
|
||||||
if (hadOld) await fs.rename(old, target).catch((e2) => onLine?.(`Rollback fehlgeschlagen: ${e2.message}`));
|
if (hadOld) await fs.rename(old, target).catch((e2) => onLine?.(`Rollback fehlgeschlagen: ${e2.message}`));
|
||||||
|
|||||||
@@ -64,10 +64,19 @@ export function validateJob(body) {
|
|||||||
}
|
}
|
||||||
analytics = { siteKey: a.siteKey, color: a.color, privacy };
|
analytics = { siteKey: a.siteKey, color: a.color, privacy };
|
||||||
}
|
}
|
||||||
|
// Öffnungszeiten live: nur der siteKey, Zusatzfelder werden verworfen.
|
||||||
|
let zeiten;
|
||||||
|
if (body.zeiten !== undefined && body.zeiten !== null) {
|
||||||
|
const z = body.zeiten;
|
||||||
|
if (!z || typeof z !== 'object' || Array.isArray(z)) return { ok: false, error: 'Ungültige Zeiten-Angaben' };
|
||||||
|
if (typeof z.siteKey !== 'string' || !RE_SITE_KEY.test(z.siteKey)) return { ok: false, error: 'Ungültiger Zeiten-Schlüssel' };
|
||||||
|
zeiten = { siteKey: z.siteKey };
|
||||||
|
}
|
||||||
// Whitelist: keine ungeprüften Client-Felder im Job
|
// Whitelist: keine ungeprüften Client-Felder im Job
|
||||||
const job = { sub, repo, sha, branch, mode, env };
|
const job = { sub, repo, sha, branch, mode, env };
|
||||||
if (index !== undefined) job.index = index;
|
if (index !== undefined) job.index = index;
|
||||||
if (displayName !== undefined) job.displayName = displayName;
|
if (displayName !== undefined) job.displayName = displayName;
|
||||||
if (analytics !== undefined) job.analytics = analytics;
|
if (analytics !== undefined) job.analytics = analytics;
|
||||||
|
if (zeiten !== undefined) job.zeiten = zeiten;
|
||||||
return { ok: true, job };
|
return { ok: true, job };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { injectZeiten, zeitenScriptTag, injectIntoHtml, injectAnalytics } from '../src/analytics-inject.js';
|
||||||
|
import { validateJob } from '../src/validate.js';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { createJobs } from '../src/jobs.js';
|
||||||
|
|
||||||
|
const BASE = 'https://project.webklar.com/embed/';
|
||||||
|
const ABASE = 'https://project.webklar.com/a/';
|
||||||
|
const KEY = 'abcdefghij234567';
|
||||||
|
const Z = (o = {}) => ({ siteKey: KEY, ...o });
|
||||||
|
const TAG = `<script data-wk-zeiten src="${BASE}zeiten.js" data-site="${KEY}" defer></script>`;
|
||||||
|
const A = () => ({ siteKey: 'abcdef0123456789abcd', color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } });
|
||||||
|
const dir = () => mkdtempSync(join(tmpdir(), 'zinj-'));
|
||||||
|
const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); };
|
||||||
|
const get = (d, f) => readFileSync(join(d, f), 'utf8');
|
||||||
|
const count = (s, sub) => s.split(sub).length - 1;
|
||||||
|
|
||||||
|
test('zeitenScriptTag: genau die vereinbarte Form', () => {
|
||||||
|
assert.equal(zeitenScriptTag(Z(), BASE), TAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tag steht vor </head>, node_modules/Punktordner übersprungen, kein Datenschutzseite', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
|
||||||
|
put(d, 'sub/seite.htm', '<head></head><p>b</p>');
|
||||||
|
put(d, 'node_modules/p/x.html', '<head></head>');
|
||||||
|
put(d, '.hidden/x.html', '<head></head>');
|
||||||
|
const lines = [];
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
|
||||||
|
assert.equal(get(d, 'index.html'), `<html><head><title>x</title>${TAG}</head><body>a</body></html>`);
|
||||||
|
assert.equal(get(d, 'sub/seite.htm'), `<head>${TAG}</head><p>b</p>`);
|
||||||
|
assert.equal(get(d, 'node_modules/p/x.html'), '<head></head>');
|
||||||
|
assert.equal(get(d, '.hidden/x.html'), '<head></head>');
|
||||||
|
assert.deepEqual(lines, ['Zeiten-Skript in 2 HTML-Datei(en) eingesetzt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zweimal einsetzen ergibt genau einen Tag', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'index.html', '<head></head>');
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.equal(count(get(d, 'index.html'), 'data-wk-zeiten'), 1);
|
||||||
|
assert.equal(get(d, 'index.html'), `<head>${TAG}</head>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alter Zeiten-Tag wird ersetzt', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'b.html', '<head><script data-wk-zeiten src="https://alt/zeiten.js" data-site="x" defer></script></head><body></body>');
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.equal(get(d, 'b.html'), `<head>${TAG}</head><body></body>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alter Tag im Kommentar wird nicht ersetzt', async () => {
|
||||||
|
const d = dir();
|
||||||
|
const old = '<script data-wk-zeiten src="https://alt/zeiten.js"></script>';
|
||||||
|
put(d, 'a.html', `<head><!-- ${old} --></head>`);
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.equal(get(d, 'a.html'), `<head><!-- ${old} -->${TAG}</head>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne head: nach <body …>; ohne body: am Dateianfang', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'a.html', '<body class="x">hi</body>');
|
||||||
|
put(d, 'b.html', '<p>nur text</p>');
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.equal(get(d, 'a.html'), `<body class="x">${TAG}hi</body>`);
|
||||||
|
assert.equal(get(d, 'b.html'), `${TAG}<p>nur text</p>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nicht-UTF8-Bytes bleiben erhalten', async () => {
|
||||||
|
const d = dir();
|
||||||
|
writeFileSync(join(d, 'l.html'), Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('</head>')]));
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.deepEqual(readFileSync(join(d, 'l.html')), Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('</head>')]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datei > 5 MB wird übersprungen (Logzeile), genau 5 MB wird bearbeitet', async () => {
|
||||||
|
const d = dir();
|
||||||
|
const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('<head></head>', 0);
|
||||||
|
const edge = Buffer.alloc(5 * 1024 * 1024, 0x20); edge.write('<head></head>', 0);
|
||||||
|
writeFileSync(join(d, 'gross.html'), big);
|
||||||
|
writeFileSync(join(d, 'grenze.html'), edge);
|
||||||
|
const lines = [];
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
|
||||||
|
assert.deepEqual(readFileSync(join(d, 'gross.html')), big);
|
||||||
|
assert.ok(get(d, 'grenze.html').includes('data-wk-zeiten'));
|
||||||
|
assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Analyse und Zeiten nacheinander: je ein Tag, in beiden Reihenfolgen und mit Wiederholung', async () => {
|
||||||
|
for (const order of ['az', 'za']) {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
|
||||||
|
put(d, 'k.html', '<body>kein head</body>');
|
||||||
|
for (const step of [...order, ...order]) {
|
||||||
|
if (step === 'a') await injectAnalytics(d, A(), { baseUrl: ABASE });
|
||||||
|
else await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
}
|
||||||
|
for (const f of ['index.html', 'k.html']) {
|
||||||
|
const s = get(d, f);
|
||||||
|
assert.equal(count(s, 'data-wk-analytics'), 1, `${order} ${f} analytics`);
|
||||||
|
assert.equal(count(s, 'data-wk-zeiten'), 1, `${order} ${f} zeiten`);
|
||||||
|
}
|
||||||
|
// Die Datenschutzseite schreibt jeder Analyse-Lauf neu; im echten Ablauf (Analyse, dann Zeiten) trägt auch sie beide Tags.
|
||||||
|
const p = get(d, 'datenschutz-analyse.html');
|
||||||
|
assert.equal(count(p, 'data-wk-analytics'), 1, `${order} Datenschutzseite analytics`);
|
||||||
|
if (order === 'az') assert.equal(count(p, 'data-wk-zeiten'), 1, 'Datenschutzseite zeiten');
|
||||||
|
assert.ok(get(d, 'index.html').includes(TAG));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nur Zeiten: keine datenschutz-analyse.html', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'index.html', '<head></head>');
|
||||||
|
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||||
|
assert.deepEqual(readdirSync(d), ['index.html']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne zeiten: nichts geändert', async () => {
|
||||||
|
const d = dir();
|
||||||
|
put(d, 'index.html', '<head></head>');
|
||||||
|
await injectZeiten(d, undefined, { baseUrl: BASE });
|
||||||
|
await injectZeiten(d, null, { baseUrl: BASE });
|
||||||
|
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ungültige Eingaben werfen mit festem Text (siteKey, Basis)', async () => {
|
||||||
|
const d = dir(); put(d, 'index.html', '<head></head>');
|
||||||
|
for (const siteKey of ['ABC', 'x'.repeat(41), 'ABCDEFGHIJ234567', 'abcdefghij23456"', undefined, 5]) {
|
||||||
|
await assert.rejects(injectZeiten(d, { siteKey }, { baseUrl: BASE }), { message: 'Zeiten: ungültiger siteKey' });
|
||||||
|
}
|
||||||
|
for (const baseUrl of ['http://x/embed/', 'https://x/embed', 'https://x/"><script>/', 'https://x/ a/', undefined, '']) {
|
||||||
|
await assert.rejects(injectZeiten(d, Z(), { baseUrl }), { message: 'Zeiten: ungültige öffentliche Basis-URL' });
|
||||||
|
}
|
||||||
|
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injectIntoHtml lehnt fremde Marker ab, nimmt gültige an', () => {
|
||||||
|
const buf = Buffer.from('<head></head>');
|
||||||
|
for (const m of ['data-wk-', 'data-wk-A', 'data-x-zeiten', 'data-wk-zeiten"', 'data-wk-a b', 'x data-wk-zeiten', 'data-wk-zeiten\n', '', 'data-wk-a.b', 5, null]) {
|
||||||
|
assert.throws(() => injectIntoHtml(buf, '<script></script>', m), /Marker/, String(m));
|
||||||
|
}
|
||||||
|
assert.equal(injectIntoHtml(buf, '<script data-wk-foo></script>', 'data-wk-foo').toString(), '<head><script data-wk-foo></script></head>');
|
||||||
|
// Vorgabe bleibt die Analyse-Marke
|
||||||
|
const old = Buffer.from('<head><script data-wk-analytics src="x"></script></head>');
|
||||||
|
assert.equal(injectIntoHtml(old, '<b></b>').toString(), '<head><b></b></head>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateJob: gültig, null, ungültiger Schlüssel, Zusatzfelder verworfen', () => {
|
||||||
|
const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
|
||||||
|
const ok = validateJob({ ...base, zeiten: { siteKey: KEY, extra: 1, url: 'https://x' } });
|
||||||
|
assert.equal(ok.ok, true);
|
||||||
|
assert.deepEqual(ok.job.zeiten, { siteKey: KEY });
|
||||||
|
assert.equal(validateJob(base).ok, true);
|
||||||
|
assert.equal('zeiten' in validateJob(base).job, false);
|
||||||
|
const nul = validateJob({ ...base, zeiten: null });
|
||||||
|
assert.equal(nul.ok, true); assert.equal('zeiten' in nul.job, false);
|
||||||
|
const both = validateJob({ ...base, analytics: A(), zeiten: Z() });
|
||||||
|
assert.equal(both.ok, true); assert.deepEqual(both.job.zeiten, { siteKey: KEY }); assert.equal(both.job.analytics.color, '#2b6cb0');
|
||||||
|
for (const z of ['x', [], 5, true]) {
|
||||||
|
const r = validateJob({ ...base, zeiten: z }); assert.equal(r.ok, false, JSON.stringify(z)); assert.equal(r.error, 'Ungültige Zeiten-Angaben');
|
||||||
|
}
|
||||||
|
for (const z of [{}, { siteKey: 'kurz' }, { siteKey: 'A'.repeat(20) }, { siteKey: 5 }, { siteKey: 'a'.repeat(41) }, { siteKey: `${KEY}"` }, { siteKey: `${KEY}\n` }]) {
|
||||||
|
const r = validateJob({ ...base, zeiten: z }); assert.equal(r.ok, false, JSON.stringify(z)); assert.equal(r.error, 'Ungültiger Zeiten-Schlüssel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig: zeitenPublicBase Vorgabe, Override, ungültig wirft', () => {
|
||||||
|
assert.equal(loadConfig({}).zeitenPublicBase, BASE);
|
||||||
|
assert.equal(loadConfig({ ZEITEN_PUBLIC_BASE: 'https://x.de/q/' }).zeitenPublicBase, 'https://x.de/q/');
|
||||||
|
assert.throws(() => loadConfig({ ZEITEN_PUBLIC_BASE: 'http://x.de/q/' }), { message: 'ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden' });
|
||||||
|
assert.throws(() => loadConfig({ ZEITEN_PUBLIC_BASE: 'https://x.de/q' }), /ZEITEN_PUBLIC_BASE/);
|
||||||
|
assert.equal(loadConfig({}).analyticsPublicBase, ABASE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Integration über publish ----
|
||||||
|
function setup(cfg = {}, fsOver = {}) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'zinjjobs-'));
|
||||||
|
const config = { giteaUrl: 'https://git.x', giteaToken: 't', previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, analyticsPublicBase: ABASE, zeitenPublicBase: BASE, ...cfg };
|
||||||
|
mkdirSync(config.previewsRoot, { recursive: true });
|
||||||
|
const git = async ({ dir: d }) => { put(d, 'index.html', '<html><head></head><body>NEU</body></html>'); put(d, 'x/y.html', '<p>y</p>'); };
|
||||||
|
const jobs = createJobs({ config, docker: { run: async () => ({ code: 0 }) }, git, mem: () => 1e10, fs: { chownR: async () => {}, ...fsOver } });
|
||||||
|
return { config, jobs };
|
||||||
|
}
|
||||||
|
const job = (o = {}) => ({ sub: 'demo', repo: 'own/rep', sha: 'abcdef1', branch: 'main', mode: 'auto', env: {}, ...o });
|
||||||
|
const wait = async (jobs, id) => { for (let i = 0; i < 400; i++) { const r = jobs.get(id); if (r.state === 'done' || r.state === 'failed') return r; await new Promise((r2) => setTimeout(r2, 5)); } throw new Error('timeout'); };
|
||||||
|
|
||||||
|
test('publish: Job mit zeiten setzt das Tag im veröffentlichten Ordner, ohne Datenschutzseite', async () => {
|
||||||
|
const s = setup();
|
||||||
|
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||||
|
assert.equal(r.state, 'done');
|
||||||
|
const t = join(s.config.previewsRoot, 'demo');
|
||||||
|
assert.ok(get(t, 'index.html').includes(TAG));
|
||||||
|
assert.ok(get(t, 'x/y.html').startsWith(TAG));
|
||||||
|
assert.ok(!get(t, 'index.html').includes('data-wk-analytics'));
|
||||||
|
assert.ok(!existsSync(join(t, 'datenschutz-analyse.html')));
|
||||||
|
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish: analytics und zeiten in einem Job: je ein Tag', async () => {
|
||||||
|
const s = setup();
|
||||||
|
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A(), zeiten: Z() })));
|
||||||
|
assert.equal(r.state, 'done');
|
||||||
|
const t = join(s.config.previewsRoot, 'demo');
|
||||||
|
for (const f of ['index.html', 'x/y.html']) {
|
||||||
|
const c = get(t, f);
|
||||||
|
assert.equal(count(c, 'data-wk-analytics'), 1, f);
|
||||||
|
assert.equal(count(c, 'data-wk-zeiten'), 1, f);
|
||||||
|
assert.ok(c.includes(TAG), f);
|
||||||
|
}
|
||||||
|
assert.ok(existsSync(join(t, 'datenschutz-analyse.html')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish: Job ohne zeiten bleibt unverändert', async () => {
|
||||||
|
const s = setup();
|
||||||
|
const r = await wait(s.jobs, s.jobs.submit(job()));
|
||||||
|
assert.equal(r.state, 'done');
|
||||||
|
assert.equal(get(join(s.config.previewsRoot, 'demo'), 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt', async () => {
|
||||||
|
const s = setup({}, { readFile: async () => { throw new Error('kaputt'); } });
|
||||||
|
const t = join(s.config.previewsRoot, 'demo');
|
||||||
|
mkdirSync(t); writeFileSync(join(t, 'index.html'), 'ALT');
|
||||||
|
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||||
|
assert.equal(r.state, 'failed');
|
||||||
|
assert.equal(get(t, 'index.html'), 'ALT');
|
||||||
|
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish: ungültige Basis-URL in der Konfiguration ⇒ failed, alter Ordner bleibt', async () => {
|
||||||
|
const s = setup({ zeitenPublicBase: 'http://kaputt/' });
|
||||||
|
const t = join(s.config.previewsRoot, 'demo');
|
||||||
|
mkdirSync(t); writeFileSync(join(t, 'index.html'), 'ALT');
|
||||||
|
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||||
|
assert.equal(r.state, 'failed');
|
||||||
|
assert.equal(get(t, 'index.html'), 'ALT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => {
|
||||||
|
const s = setup();
|
||||||
|
const { workDir } = await s.jobs.prepareWorkspace(job({ zeiten: Z() }), { workDir: join(s.config.workRoot, 'dev', 'x') });
|
||||||
|
assert.equal(get(workDir, 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user