import { test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import http from 'node:http'; import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { gunzipSync } from 'node:zlib'; import { openDb } from '../src/db.js'; import { createPublicServer, createAdminServer } from '../src/server.js'; const DIST = new URL('../client/dist/', import.meta.url); const KEY = 'abcdef0123456789abcd'; const OFFKEY = 'offoffoffoffoffoffoff'; async function start(config = {}) { const db = openDb(':memory:'); db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]'); db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,0)').run(OFFKEY, 'p', '[]'); const server = createPublicServer({ config, db }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }; } // fetch dekodiert gzip selbst; für den rohen Body http.request verwenden. function raw(base, path, headers = {}, method = 'GET') { return new Promise((resolve, reject) => { const req = http.request(base + path, { method, headers }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); }); req.on('error', reject); req.end(); }); } for (const file of ['w.js', 'h.js']) { test(`/a/${file}: Header, identity und gzip liefern den dist-Inhalt`, async () => { const s = await start(); const want = readFileSync(new URL(file, DIST)); const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`; const id = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'identity' }); assert.equal(id.status, 200); assert.deepEqual(id.body, want); assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8'); assert.equal(id.headers['cache-control'], 'public, max-age=3600'); assert.equal(id.headers.etag, etag); assert.equal(id.headers.vary, 'Accept-Encoding'); assert.equal(id.headers['x-content-type-options'], 'nosniff'); assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin'); assert.equal(id.headers['access-control-allow-origin'], '*'); assert.equal(id.headers['content-encoding'], undefined); const gz = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'gzip, deflate' }); assert.equal(gz.status, 200); assert.equal(gz.headers['content-encoding'], 'gzip'); assert.equal(gz.headers.etag, etag); assert.ok(gz.body.length < want.length); assert.deepEqual(gunzipSync(gz.body), want); s.close(); }); } test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => { const s = await start(); const first = await raw(s.base, `/a/w.js?s=${KEY}`); const hit = await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': first.headers.etag }); assert.equal(hit.status, 304); assert.equal(hit.body.length, 0); assert.equal(hit.headers.etag, first.headers.etag); assert.equal(hit.headers['cache-control'], 'public, max-age=3600'); assert.equal((await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': '"anders"' })).status, 200); s.close(); }); test('HEAD: Header wie GET, kein Body', async () => { const s = await start(); const get = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' }); const head = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' }, 'HEAD'); assert.equal(head.status, 200); assert.equal(head.body.length, 0); assert.equal(head.headers['content-length'], String(get.body.length)); assert.equal(head.headers.etag, get.headers.etag); assert.equal((await raw(s.base, '/a/h.js', {}, 'POST')).status, 405); s.close(); }); test('Admin-Listener liefert die Skripte nicht', async () => { const server = createAdminServer({ config: { adminToken: 't' }, db: openDb(':memory:') }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; assert.equal((await fetch(`${base}/a/w.js`)).status, 404); assert.equal((await fetch(`${base}/a/h.js`)).status, 404); server.close(); }); test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', async () => { const dir = mkdtempSync(join(tmpdir(), 'dist-')); writeFileSync(join(dir, 'w.js'), 'console.log(1)'); const orig = console.error; const logged = []; console.error = (...a) => logged.push(a.join(' ')); let s; try { s = await start({ distDir: dir }); } finally { console.error = orig; } assert.ok(logged.some((l) => l.includes('h.js') && l.includes('fehlt'))); assert.equal((await fetch(`${s.base}/a/w.js`)).status, 200); assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503); s.close(); }); const EMPTY = '/* WEBklar Analyse: aus */\n'; for (const [name, q] of [['unbekannter Schlüssel', '?s=unbekannt0123456789ab'], ['ungültiger Schlüssel', '?s=ABC'], ['ohne s', ''], ['ausgeschaltete Website', `?s=${OFFKEY}`]]) { test(`w.js ${name}: leeres Skript`, async () => { const s = await start(); const r = await raw(s.base, `/a/w.js${q}`, { 'Accept-Encoding': 'gzip' }); assert.equal(r.status, 200); assert.equal(r.body.toString(), EMPTY); assert.equal(r.headers['content-type'], 'text/javascript; charset=utf-8'); assert.equal(r.headers['cache-control'], 'public, max-age=60'); assert.equal(r.headers['x-content-type-options'], 'nosniff'); assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin'); assert.equal(r.headers['access-control-allow-origin'], '*'); assert.equal(r.headers.vary, 'Accept-Encoding'); assert.equal(r.headers['content-encoding'], undefined); assert.equal(r.headers.etag, undefined); s.close(); }); } test('Ende-zu-Ende: Ausschalten wirkt sofort, Einschalten ebenfalls', async () => { const db = openDb(':memory:'); const pub = createPublicServer({ config: {}, db }); const adm = createAdminServer({ config: { adminToken: 't' }, db }); await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r)))); const pb = `http://127.0.0.1:${pub.address().port}`; const ab = `http://127.0.0.1:${adm.address().port}`; const put = (active) => fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active }) }); const get = async () => (await raw(pb, `/a/w.js?s=${KEY}`)).body.toString(); assert.equal(await get(), EMPTY); assert.equal((await put(true)).status, 200); assert.notEqual(await get(), EMPTY); assert.equal((await put(false)).status, 200); assert.equal(await get(), EMPTY); assert.equal((await put(true)).status, 200); assert.notEqual(await get(), EMPTY); pub.close(); adm.close(); });