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 = ``; 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 , node_modules/Punktordner übersprungen, kein Datenschutzseite', async () => { const d = dir(); put(d, 'index.html', 'xa'); put(d, 'sub/seite.htm', '

b

'); put(d, 'node_modules/p/x.html', ''); put(d, '.hidden/x.html', ''); const lines = []; await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) }); assert.equal(get(d, 'index.html'), `x${TAG}a`); assert.equal(get(d, 'sub/seite.htm'), `${TAG}

b

`); assert.equal(get(d, 'node_modules/p/x.html'), ''); assert.equal(get(d, '.hidden/x.html'), ''); 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', ''); 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'), `${TAG}`); }); test('alter Zeiten-Tag wird ersetzt', async () => { const d = dir(); put(d, 'b.html', ''); await injectZeiten(d, Z(), { baseUrl: BASE }); assert.equal(get(d, 'b.html'), `${TAG}`); }); test('alter Tag im Kommentar wird nicht ersetzt', async () => { const d = dir(); const old = ''; put(d, 'a.html', ``); await injectZeiten(d, Z(), { baseUrl: BASE }); assert.equal(get(d, 'a.html'), `${TAG}`); }); test('ohne head: nach ; ohne body: am Dateianfang', async () => { const d = dir(); put(d, 'a.html', 'hi'); put(d, 'b.html', '

nur text

'); await injectZeiten(d, Z(), { baseUrl: BASE }); assert.equal(get(d, 'a.html'), `${TAG}hi`); assert.equal(get(d, 'b.html'), `${TAG}

nur text

`); }); test('Nicht-UTF8-Bytes bleiben erhalten', async () => { const d = dir(); writeFileSync(join(d, 'l.html'), Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('')])); await injectZeiten(d, Z(), { baseUrl: BASE }); assert.deepEqual(readFileSync(join(d, 'l.html')), Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('')])); }); 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('', 0); const edge = Buffer.alloc(5 * 1024 * 1024, 0x20); edge.write('', 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', 'xa'); put(d, 'k.html', 'kein head'); 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', ''); 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', ''); await injectZeiten(d, undefined, { baseUrl: BASE }); await injectZeiten(d, null, { baseUrl: BASE }); assert.equal(get(d, 'index.html'), ''); }); test('ungültige Eingaben werfen mit festem Text (siteKey, Basis)', async () => { const d = dir(); put(d, 'index.html', ''); 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/">', m), /Marker/, String(m)); } assert.equal(injectIntoHtml(buf, '', 'data-wk-foo').toString(), ''); // Vorgabe bleibt die Analyse-Marke const old = Buffer.from(''); assert.equal(injectIntoHtml(old, '').toString(), ''); }); 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', 'NEU'); put(d, 'x/y.html', '

y

'); }; 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'), 'NEU'); }); 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'), 'NEU'); });