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>
234 lines
9.1 KiB
JavaScript
234 lines
9.1 KiB
JavaScript
'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/);
|
|
});
|