Files
Webklar-Kundenbereich/runner/test/analytics-inject.test.js
T

174 lines
9.6 KiB
JavaScript

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 { 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/a/';
const KEY = 'abcdef0123456789abcd';
const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o });
const TAG = `<script data-wk-analytics src="${BASE}w.js" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
const dir = () => mkdtempSync(join(tmpdir(), 'inj-'));
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('mehrere Dateien inkl. Unterordner: Tag vor </head>, node_modules/Punktordner übersprungen', 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>');
await injectAnalytics(d, A(), { baseUrl: BASE });
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>');
});
test('zweimal ausführen: kein Doppel; alter Tag wird ersetzt', async () => {
const d = dir();
put(d, 'index.html', '<head></head>');
await injectAnalytics(d, A(), { baseUrl: BASE });
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(count(get(d, 'index.html'), 'data-wk-analytics'), 1);
put(d, 'b.html', '<head><script data-wk-analytics src="https://alt/w.js" data-site="x" defer></script></head><body></body>');
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'b.html'), `<head>${TAG}</head><body></body>`);
});
test('ohne head: nach <body …>; ohne body: am Dateianfang; großes </HEAD>', async () => {
const d = dir();
put(d, 'a.html', '<body class="x">hi</body>');
put(d, 'b.html', '<p>nur text</p>');
put(d, 'c.html', '<HEAD><TITLE>x</TITLE></HEAD><BODY>y</BODY>');
await injectAnalytics(d, A(), { 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>`);
assert.equal(get(d, 'c.html'), `<HEAD><TITLE>x</TITLE>${TAG}</HEAD><BODY>y</BODY>`);
});
test('Nicht-UTF8-Bytes bleiben erhalten', async () => {
const d = dir();
const before = Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('</head>')]);
writeFileSync(join(d, 'l.html'), before);
await injectAnalytics(d, A(), { baseUrl: BASE });
const after = readFileSync(join(d, 'l.html'));
assert.deepEqual(after, 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)', async () => {
const d = dir();
const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('<head></head>', 0);
writeFileSync(join(d, 'gross.html'), big);
const lines = [];
await injectAnalytics(d, A(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
assert.deepEqual(readFileSync(join(d, 'gross.html')), big);
assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l)));
});
test('Datenschutzseite: Platzhalter maskiert, Skript auch dort, Pflichtabschnitte', async () => {
const d = dir();
put(d, 'index.html', '<head></head>');
await injectAnalytics(d, A({ privacy: { company: '<script>alert(1)</script> & Co', address: 'A "B" <C>', email: "x'y@z.de" } }), { baseUrl: BASE });
const p = get(d, 'datenschutz-analyse.html');
assert.ok(p.includes('&lt;script&gt;alert(1)&lt;/script&gt; &amp; Co'));
assert.ok(!p.includes('<script>alert(1)'));
assert.ok(p.includes('A &quot;B&quot; &lt;C&gt;') && p.includes('x&#39;y@z.de'));
assert.ok(!p.includes('{{'));
assert.equal(count(p, 'data-wk-analytics'), 1);
for (const s of ['Verantwortlicher', 'Zweck', 'Einwilligung', 'Rechtsgrundlage', 'Auftragsverarbeiter', 'Hetzner', 'Speicherdauer', 'Cookie-Einstellungen', 'Rechte']) assert.ok(p.includes(s), s);
});
test('ohne analytics: nichts geändert, keine Seite', async () => {
const d = dir();
put(d, 'index.html', '<head></head>');
await injectAnalytics(d, undefined, { baseUrl: BASE });
assert.equal(get(d, 'index.html'), '<head></head>');
assert.deepEqual(readdirSync(d), ['index.html']);
});
test('ungültige Eingaben werfen (siteKey, Farbe, Basis)', async () => {
const d = dir(); put(d, 'index.html', '<head></head>');
await assert.rejects(injectAnalytics(d, A({ siteKey: 'ABC' }), { baseUrl: BASE }));
await assert.rejects(injectAnalytics(d, A({ color: 'red' }), { baseUrl: BASE }));
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'http://x/a/' }));
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'https://x/a' }));
assert.equal(get(d, 'index.html'), '<head></head>');
});
test('validateJob: analytics streng, Weiterreichen in den Job', () => {
const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
const ok = validateJob({ ...base, analytics: { ...A(), extra: 1 } });
assert.equal(ok.ok, true);
assert.deepEqual(ok.job.analytics, A());
assert.equal(validateJob(base).job.analytics, undefined);
const bad = [
A({ siteKey: 'kurz' }), A({ siteKey: 'A'.repeat(20) }), A({ color: '#ABCDEF' }), A({ color: '#abc' }),
A({ privacy: undefined }), A({ privacy: { ...A().privacy, company: 'x'.repeat(201) } }),
A({ privacy: { ...A().privacy, address: 'x'.repeat(301) } }), A({ privacy: { ...A().privacy, email: 'x'.repeat(201) } }),
A({ privacy: { ...A().privacy, company: 'a\nb' } }), A({ privacy: { ...A().privacy, email: 5 } }), 'x', [], null,
];
for (const a of bad) { const r = validateJob({ ...base, analytics: a }); assert.equal(r.ok, false, JSON.stringify(a)); assert.match(r.error, /Analyse/); }
});
test('loadConfig: analyticsPublicBase Vorgabe, Override, ungültig wirft', () => {
assert.equal(loadConfig({}).analyticsPublicBase, BASE);
assert.equal(loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q/' }).analyticsPublicBase, 'https://x.de/q/');
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'http://x.de/q/' }));
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q' }));
});
// ---- Integration über publish ----
function setup(cfg = {}, fsOver = {}) {
const root = mkdtempSync(join(tmpdir(), 'injjobs-'));
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: 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 analytics injiziert im veröffentlichten Ordner', async () => {
const s = setup();
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A() })));
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(existsSync(join(t, 'datenschutz-analyse.html')));
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
});
test('publish: Job ohne analytics bleibt unverändert', async () => {
const s = setup();
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'done');
const t = join(s.config.previewsRoot, 'demo');
assert.equal(get(t, 'index.html'), '<html><head></head><body>NEU</body></html>');
assert.ok(!existsSync(join(t, 'datenschutz-analyse.html')));
});
test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt (V4)', 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({ analytics: A() })));
assert.equal(r.state, 'failed');
assert.equal(get(t, 'index.html'), 'ALT');
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
});
test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => {
const s = setup();
const { workDir } = await s.jobs.prepareWorkspace(job({ analytics: A() }), { workDir: join(s.config.workRoot, 'dev', 'x') });
assert.equal(get(workDir, 'index.html'), '<html><head></head><body>NEU</body></html>');
assert.ok(!existsSync(join(workDir, 'datenschutz-analyse.html')));
});