chore(deploy): nginx härten, deploy.sh mit Sperre, Zeitlimit und ohne Webhook-Geheimnis

nginx: server_tokens off, HSTS, exaktes /v1 liefert 404 (Regex-Block statt
^~ /v1/). deploy.sh: AIRCARD_WEBHOOK_TOKEN nicht an npm, Sperrverzeichnis
gegen parallele Läufe (verwaiste Sperren werden übernommen), timeout 900
für npm ci und Build. Neuer Test deploy/deploy.test.js gegen das echte Skript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 11:08:09 +00:00
1 parent 436ea4f4a5
commit 144181f2c9
5 files changed
+302 -8

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
'use strict';
// Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub).
// Aufruf: node --test deploy/deploy.test.js
// Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg.
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const DEPLOY_SH = path.join(__dirname, 'deploy.sh');
// Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh".
const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' ');
const GIT_ENV = {
GIT_AUTHOR_NAME: 't',
GIT_AUTHOR_EMAIL: 't@example.invalid',
GIT_COMMITTER_NAME: 't',
GIT_COMMITTER_EMAIL: 't@example.invalid',
};
const tmpDirs = [];
const children = [];
afterEach(() => {
for (const child of children.splice(0)) {
// Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript.
if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL');
}
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(condition, what, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
await pause(20);
}
}
function git(args, cwd) {
const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' });
assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`);
return res.stdout;
}
/** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */
function setup({ withTimeoutStub = false } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-'));
tmpDirs.push(dir);
const bare = path.join(dir, 'remote', 'u', 'r.git');
fs.mkdirSync(bare, { recursive: true });
git(['init', '--bare', '--quiet', '-b', 'main', bare]);
const work = path.join(dir, 'seed');
git(['clone', '--quiet', bare, work]);
fs.mkdirSync(path.join(work, 'web'));
fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n');
git(['add', '-A'], work);
git(['commit', '--quiet', '-m', 'seed'], work);
git(['push', '--quiet', 'origin', 'HEAD:main'], work);
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin);
const npmLog = path.join(dir, 'npm.log');
const release = path.join(dir, 'release');
fs.writeFileSync(
path.join(bin, 'npm'),
[
'#!/bin/sh',
`echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`,
// Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen).
`if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`,
'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi',
'if [ "$1" = run ]; then mkdir -p dist && echo "<html>ok</html>" > dist/index.html; fi',
'exit 0',
'',
].join('\n'),
{ mode: 0o755 },
);
const timeoutLog = path.join(dir, 'timeout.log');
if (withTimeoutStub) {
fs.writeFileSync(
path.join(bin, 'timeout'),
['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'),
{ mode: 0o755 },
);
}
const repoDir = path.join(dir, 'app');
const outDir = path.join(dir, 'out');
fs.mkdirSync(repoDir);
fs.mkdirSync(outDir);
const env = {
...process.env,
...GIT_ENV,
PATH: `${bin}:${process.env.PATH}`,
GITEA_URL: `file://${path.join(dir, 'remote')}`,
GITEA_USER: 'u',
GITEA_REPO: 'r',
GITEA_TOKEN: '',
REPO_DIR: repoDir,
OUT_DIR: outDir,
DEPLOY_BRANCH: 'main',
DEPLOY_TRIGGER: 'webhook',
AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook',
VITE_TEST_VAR: 'bleibt',
};
const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock');
const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []);
const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []);
return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls };
}
function runSync(ctx, extraEnv = {}) {
const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 });
return { status: res.status, out: `${res.stdout}${res.stderr}` };
}
function runAsync(ctx, extraEnv = {}) {
const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], {
env: { ...ctx.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
children.push(child);
const result = { out: '', status: null, done: null };
child.stdout.on('data', (d) => (result.out += d));
child.stderr.on('data', (d) => (result.out += d));
result.done = new Promise((resolve) =>
child.once('exit', (code) => {
result.status = code;
resolve(result);
}),
);
return result;
}
test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => {
const ctx = setup();
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), '<html>ok</html>');
const calls = ctx.npmCalls();
assert.equal(calls.length, 2, calls.join('\n'));
for (const call of calls) {
assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`);
assert.match(call, /GITEA=unset/);
assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten');
}
assert.ok(!res.out.includes('geheim-webhook'));
});
test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => {
const ctx = setup();
assert.equal(runSync(ctx).status, 0);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => {
const ctx = setup();
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
assert.equal(res.status, 1);
assert.match(res.out, /ERROR: /);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => {
const ctx = setup();
const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' });
await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs');
assert.equal(fs.existsSync(ctx.lockDir), true);
const second = runSync(ctx);
assert.equal(second.status, 0, second.out);
assert.match(second.out, /läuft bereits/);
assert.doesNotMatch(second.out, /Deployment abgeschlossen/);
assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten');
assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben');
fs.writeFileSync(ctx.release, '');
await first.done;
assert.equal(first.status, 0, first.out);
assert.match(first.out, /Deployment abgeschlossen/);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => {
const ctx = setup();
// Erster Lauf legt das Repo an.
assert.equal(runSync(ctx).status, 0);
fs.mkdirSync(ctx.lockDir);
fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n');
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => {
const ctx = setup();
assert.equal(runSync(ctx).status, 0);
fs.mkdirSync(ctx.lockDir);
// Kein pid-Eintrag, aber alt genug (über 60 Minuten).
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(ctx.lockDir, old, old);
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
});
const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen';
test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => {
const ctx = setup({ withTimeoutStub: true });
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
const calls = ctx.timeoutCalls();
assert.equal(calls.length, 2, calls.join('\n'));
assert.match(calls[0], /^timeout 900 npm ci /);
assert.match(calls[1], /^timeout 900 npm run build/);
});
test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => {
const ctx = setup({ withTimeoutStub: true });
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
assert.equal(res.status, 1);
assert.match(res.out, /ERROR: npm ci fehlgeschlagen/);
});