feat(deploy): Webhook, Build-Skript, nginx, Traefik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30f4ddc0f4
commit
906bdf1033
7 files changed
+1084
No files matched your search
@@ -0,0 +1,451 @@
|
||||
'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/<x> ergibt <x>', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user