'use strict'; // Tests für deploy/webhook-server.js. Aufruf: node --test deploy/webhook-server.test.js // Nur Node-Bordmittel; die Server laufen auf Port 0 und werden nach jedem Test geschlossen. const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const http = require('node:http'); const net = require('node:net'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { verifySignature, branchFromPayload, createServer } = require('./webhook-server.js'); const SECRET = 'test-secret-0123456789abcdef-test-secret-0123456789abcdef'; function sign(raw, secret = SECRET) { return crypto.createHmac('sha256', secret).update(raw).digest('hex'); } function pushBody(ref) { return Buffer.from(JSON.stringify({ ref, after: '0123456789abcdef0123456789abcdef01234567' })); } async function waitFor(condition, what, timeoutMs = 3000) { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`); await new Promise((resolve) => setTimeout(resolve, 10)); } } const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // ---- Aufräumen ------------------------------------------------------------- const servers = []; const children = []; const tmpDirs = []; afterEach(async () => { for (const child of children.splice(0)) { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await new Promise((resolve) => child.once('exit', resolve)); } } for (const server of servers.splice(0)) { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } for (const dir of tmpDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); // ---- Hilfen für Server-Tests ----------------------------------------------- /** Startet createServer() auf Port 0. runDeploy-Aufrufe werden in `calls` gesammelt. */ async function startServer(overrides = {}) { const calls = []; const logs = []; const options = { secret: SECRET, branch: 'main', runDeploy: async (trigger) => { calls.push(trigger); }, log: (line) => logs.push(line), ...overrides, }; const server = createServer(options); servers.push(server); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); return { server, port: server.address().port, calls, logs }; } /** Ein runDeploy, das so lange blockiert, bis der Test den Lauf freigibt. */ function blockedDeploy() { const calls = []; const releases = []; const runDeploy = (trigger) => { calls.push(trigger); return new Promise((resolve) => releases.push(resolve)); }; return { calls, releases, runDeploy }; } function request(port, { method = 'POST', path: urlPath = '/webhook/deploy', headers = {}, body } = {}) { return new Promise((resolve, reject) => { const req = http.request({ host: '127.0.0.1', port, method, path: urlPath, headers }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); let json = null; try { json = JSON.parse(text); } catch { // kein JSON } resolve({ status: res.statusCode, headers: res.headers, text, json }); }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } function signedPush(port, ref, secret = SECRET) { const body = pushBody(ref); return request(port, { headers: { 'content-type': 'application/json', 'x-gitea-signature': sign(body, secret) }, body, }); } // ---- verifySignature -------------------------------------------------------- test('verifySignature: gültige Signatur wird akzeptiert', () => { const raw = Buffer.from('{"ref":"refs/heads/main"}'); assert.equal(verifySignature(raw, sign(raw), SECRET), true); }); test('verifySignature: falscher Rohbody oder falsches Secret wird abgelehnt', () => { const raw = Buffer.from('{"ref":"refs/heads/main"}'); assert.equal(verifySignature(Buffer.from('{"ref":"refs/heads/dev"}'), sign(raw), SECRET), false); assert.equal(verifySignature(raw, sign(raw, 'anderes-secret'), SECRET), false); assert.equal(verifySignature(raw, sign(raw), 'anderes-secret'), false); }); test('verifySignature: ungültige oder unpassend lange Signaturen liefern false ohne Exception', () => { const raw = Buffer.from('{}'); const valid = sign(raw); assert.equal(verifySignature(raw, 'deadbeef', SECRET), false); assert.equal(verifySignature(raw, valid.slice(0, -1), SECRET), false); assert.equal(verifySignature(raw, `${valid}00`, SECRET), false); assert.equal(verifySignature(raw, 'z'.repeat(valid.length), SECRET), false); assert.equal(verifySignature(raw, `sha256=${valid}`, SECRET), false); }); test('verifySignature: leeres Secret ist immer false', () => { const raw = Buffer.from('{}'); assert.equal(verifySignature(raw, sign(raw, ''), ''), false); assert.equal(verifySignature(raw, sign(raw), ''), false); assert.equal(verifySignature(raw, sign(raw), undefined), false); }); test('verifySignature: fehlender Header ist false', () => { const raw = Buffer.from('{}'); assert.equal(verifySignature(raw, undefined, SECRET), false); assert.equal(verifySignature(raw, '', SECRET), false); }); // ---- branchFromPayload ------------------------------------------------------ test('branchFromPayload: refs/heads/ ergibt ', () => { assert.equal(branchFromPayload({ ref: 'refs/heads/main' }), 'main'); assert.equal(branchFromPayload({ ref: 'refs/heads/feature/neu' }), 'feature/neu'); }); test('branchFromPayload: alles andere ergibt null', () => { assert.equal(branchFromPayload({}), null); assert.equal(branchFromPayload(null), null); assert.equal(branchFromPayload('refs/heads/main'), null); assert.equal(branchFromPayload({ ref: 42 }), null); assert.equal(branchFromPayload({ ref: 'refs/tags/v1' }), null); assert.equal(branchFromPayload({ ref: 'refs/heads/' }), null); }); // ---- Server ----------------------------------------------------------------- test('Server: unsignierter POST ergibt 401 und startet keinen Lauf', async () => { const { port, calls } = await startServer(); const res = await request(port, { headers: { 'content-type': 'application/json' }, body: pushBody('refs/heads/main'), }); assert.equal(res.status, 401); await pause(20); assert.deepEqual(calls, []); }); test('Server: POST mit falscher Signatur ergibt 401', async () => { const { port, calls } = await startServer(); const res = await signedPush(port, 'refs/heads/main', 'falsches-secret'); assert.equal(res.status, 401); assert.deepEqual(calls, []); }); test('Server: Signatur nur im Header x-gitea-signature, keine Fallbacks', async () => { const { port, calls } = await startServer(); const body = pushBody('refs/heads/main'); const res = await request(port, { headers: { 'x-hub-signature-256': `sha256=${sign(body)}`, 'x-hub-signature': `sha256=${sign(body)}`, 'x-gitea-token': SECRET, }, body, }); assert.equal(res.status, 401); assert.deepEqual(calls, []); }); test('Server: leeres Secret lehnt jeden Push ab', async () => { const { port, calls } = await startServer({ secret: '' }); const body = pushBody('refs/heads/main'); const res = await request(port, { headers: { 'x-gitea-signature': sign(body, '') }, body }); assert.equal(res.status, 401); assert.deepEqual(calls, []); }); test('Server: signierter Push auf main ergibt 202 und runDeploy("webhook")', async () => { const { port, calls } = await startServer(); const res = await signedPush(port, 'refs/heads/main'); assert.equal(res.status, 202); assert.deepEqual(res.json, { queued: true }); await waitFor(() => calls.length === 1, 'runDeploy'); assert.deepEqual(calls, ['webhook']); }); test('Server: Push auf einen anderen Branch wird mit 200 ignoriert', async () => { const { port, calls } = await startServer(); const res = await signedPush(port, 'refs/heads/dev'); assert.equal(res.status, 200); assert.deepEqual(res.json, { ignored: true }); await pause(20); assert.deepEqual(calls, []); }); test('Server: Tag-Push und Body ohne Branch werden mit 200 ignoriert', async () => { const { port, calls } = await startServer(); assert.deepEqual((await signedPush(port, 'refs/tags/v1')).json, { ignored: true }); const notJson = Buffer.from('das ist kein JSON'); const res = await request(port, { headers: { 'x-gitea-signature': sign(notJson) }, body: notJson }); assert.equal(res.status, 200); assert.deepEqual(res.json, { ignored: true }); assert.deepEqual(calls, []); }); test('Server: alles andere ergibt 404', async () => { const { port, calls } = await startServer(); assert.equal((await request(port, { method: 'GET', path: '/' })).status, 404); assert.equal((await request(port, { method: 'GET', path: '/webhook/deploy' })).status, 404); assert.equal((await request(port, { method: 'POST', path: '/' })).status, 404); assert.equal((await request(port, { method: 'POST', path: '/webhook/deploy/x' })).status, 404); assert.equal((await request(port, { method: 'GET', path: '/healthz' })).status, 404); assert.deepEqual(calls, []); }); test('Server: zu großer Body ergibt 413 und startet keinen Lauf', async () => { const { port, calls } = await startServer(); const body = Buffer.alloc(1024 * 1024 + 1, 'a'); const res = await request(port, { headers: { 'x-gitea-signature': sign(body) }, body }); assert.equal(res.status, 413); await pause(20); assert.deepEqual(calls, []); }); test('Server: drei schnelle Pushes bei blockiertem runDeploy -> alle 202, danach genau 2 Läufe', async () => { const deploy = blockedDeploy(); const { port } = await startServer({ runDeploy: deploy.runDeploy }); const first = await signedPush(port, 'refs/heads/main'); const second = await signedPush(port, 'refs/heads/main'); const third = await signedPush(port, 'refs/heads/main'); assert.deepEqual([first.status, second.status, third.status], [202, 202, 202]); assert.deepEqual([first.json, second.json, third.json], [{ queued: true }, { queued: true }, { queued: true }]); // Solange der erste Lauf hängt, startet kein zweiter (nacheinander, nie parallel). await pause(50); assert.equal(deploy.calls.length, 1); deploy.releases[0](); await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf'); deploy.releases[1](); await pause(50); assert.deepEqual(deploy.calls, ['webhook', 'webhook']); assert.equal(deploy.releases.length, 2); }); test('Server: ein Push nach Ende aller Läufe startet wieder einen Lauf', async () => { const deploy = blockedDeploy(); const { port } = await startServer({ runDeploy: deploy.runDeploy }); await signedPush(port, 'refs/heads/main'); deploy.releases[0](); await pause(30); await signedPush(port, 'refs/heads/main'); await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf'); deploy.releases[1](); await pause(30); assert.deepEqual(deploy.calls, ['webhook', 'webhook']); }); test('Server: ein fehlgeschlagener Lauf wird protokolliert, der Server läuft weiter', async () => { const calls = []; const { port, logs } = await startServer({ runDeploy: async (trigger) => { calls.push(trigger); throw new Error('deploy exited with code 1'); }, }); assert.equal((await signedPush(port, 'refs/heads/main')).status, 202); await waitFor(() => logs.some((line) => line.includes('deploy exited with code 1')), 'Fehlerzeile im Log'); assert.equal((await signedPush(port, 'refs/heads/main')).status, 202); await waitFor(() => calls.length === 2, 'zweiter Lauf nach Fehler'); }); test('Server: synchron werfendes runDeploy bringt den Server nicht zum Absturz', async () => { const calls = []; const { port } = await startServer({ runDeploy: () => { calls.push('x'); throw new Error('kaputt'); }, }); assert.equal((await signedPush(port, 'refs/heads/main')).status, 202); await waitFor(() => calls.length === 1, 'Lauf'); assert.equal((await signedPush(port, 'refs/heads/main')).status, 202); await waitFor(() => calls.length === 2, 'zweiter Lauf'); }); test('Server: enqueueDeploy teilt die Warteschlange; ein wartender poll wird vom Webhook überholt', async () => { const deploy = blockedDeploy(); const { server, port } = await startServer({ runDeploy: deploy.runDeploy }); server.enqueueDeploy('initial'); server.enqueueDeploy('poll'); // wartet const res = await signedPush(port, 'refs/heads/main'); assert.equal(res.status, 202); await pause(30); assert.deepEqual(deploy.calls, ['initial']); deploy.releases[0](); await waitFor(() => deploy.calls.length === 2, 'zweiter Lauf'); deploy.releases[1](); await pause(30); // Der Webhook baut immer; deshalb darf der wartende Lauf kein überspringbarer "poll" bleiben. assert.deepEqual(deploy.calls, ['initial', 'webhook']); }); test('Server: Logzeilen beginnen mit [webhook] und enthalten weder Secret noch Signatur', async () => { const { port, logs } = await startServer(); const body = pushBody('refs/heads/main'); const signature = sign(body); await request(port, { headers: { 'x-gitea-signature': signature }, body }); await request(port, { headers: { 'x-gitea-signature': 'deadbeef' }, body }); await signedPush(port, 'refs/heads/dev'); await pause(30); assert.ok(logs.length >= 3, `zu wenige Logzeilen: ${logs.length}`); for (const line of logs) { assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`); assert.ok(!line.includes(SECRET), 'Secret im Log'); assert.ok(!line.includes(signature), 'Signatur im Log'); } }); // ---- Start als Programm (require.main === module) --------------------------- const SERVER_JS = path.join(__dirname, 'webhook-server.js'); function freePort() { return new Promise((resolve, reject) => { const probe = net.createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); } function makeTmpDir() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-webhook-')); tmpDirs.push(dir); return dir; } function startProgram(env) { const child = spawn(process.execPath, [SERVER_JS], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); children.push(child); const output = { text: '' }; child.stdout.on('data', (chunk) => (output.text += chunk)); child.stderr.on('data', (chunk) => (output.text += chunk)); return { child, output }; } test('Start ohne AIRCARD_WEBHOOK_TOKEN: Fehlermeldung ohne Wert, Exit-Code 1', async () => { for (const env of [{}, { AIRCARD_WEBHOOK_TOKEN: '' }]) { const { child, output } = startProgram({ ...env, AIRCARD_WEBHOOK_PORT: String(await freePort()) }); const code = await new Promise((resolve) => child.once('exit', resolve)); assert.equal(code, 1); assert.match(output.text, /AIRCARD_WEBHOOK_TOKEN/); } }); test('Start: initial-Lauf, Webhook-Lauf mit DEPLOY_TRIGGER und weiterlaufender Server nach Fehlschlag', async () => { const dir = makeTmpDir(); const record = path.join(dir, 'triggers.txt'); const script = path.join(dir, 'deploy.sh'); const secretSeen = path.join(dir, 'secret-seen.txt'); // Protokolliert (ob das Webhook-Secret im Env ankommt, dann den Trigger); scheitert, solange "fail" existiert. fs.writeFileSync( script, `#!/bin/sh\necho "\${AIRCARD_WEBHOOK_TOKEN:-leer}" >> "${secretSeen}"\necho "$DEPLOY_TRIGGER" >> "${record}"\n` + `if [ -f "${path.join(dir, 'fail')}" ]; then exit 3; fi\n`, ); fs.writeFileSync(path.join(dir, 'fail'), ''); const port = await freePort(); const { child, output } = startProgram({ AIRCARD_WEBHOOK_TOKEN: SECRET, AIRCARD_WEBHOOK_PORT: String(port), AIRCARD_DEPLOY_BRANCH: 'main', AIRCARD_DEPLOY_SCRIPT: script, POLL_INTERVAL_MINUTES: '0', }); const triggers = () => (fs.existsSync(record) ? fs.readFileSync(record, 'utf8').trim().split('\n') : []); await waitFor(() => triggers().length === 1, 'initial-Lauf', 8000); assert.deepEqual(triggers(), ['initial']); await waitFor(() => /deploy exited with code 3/.test(output.text), 'Fehlerzeile', 5000); // Der Server lebt trotz fehlgeschlagenem Lauf und nimmt signierte Pushes an. fs.rmSync(path.join(dir, 'fail')); const res = await signedPush(port, 'refs/heads/main'); assert.equal(res.status, 202); await waitFor(() => triggers().length === 2, 'webhook-Lauf', 8000); assert.deepEqual(triggers(), ['initial', 'webhook']); assert.equal(child.exitCode, null); // Das Deploy-Skript (und damit npm) bekommt das Webhook-Secret nicht. assert.deepEqual(fs.readFileSync(secretSeen, 'utf8').trim().split('\n'), ['leer', 'leer']); for (const line of output.text.split('\n').filter(Boolean)) { assert.ok(line.startsWith('[webhook]'), `Zeile beginnt nicht mit [webhook]: ${line}`); assert.ok(!line.includes(SECRET), 'Secret im Log'); } });