import { chromium } from '@playwright/test'; import { spawnSync } from 'node:child_process'; /** * Der Abnahmelauf gegen das Docker-Abbild. * * `abnahme.mjs` prüft die Anwendung über `next start`. Betrieben wird sie aber * als Container – und was dort fehlt, fällt dabei nicht auf. So war es mit dem * Kontowerkzeug: Die README beschrieb `node werkzeuge/konto.js`, im Abbild lag * es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da. * * Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg * aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sperre bei * Fehlversuchen, Sicherung. * * node scripts/pruefungen/container.mjs * * Voraussetzung: Docker läuft, Port 3398 ist frei (LERNBAR_CONTAINER_PORT). */ const PORT = Number(process.env.LERNBAR_CONTAINER_PORT ?? 3398); const BASIS = `http://localhost:${PORT}`; const ABBILD = 'lernbar:abnahme'; const NAME = `lernbar-abnahme-${Date.now()}`; const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'container-ausbilder' }; let fehler = 0; function pruefe(bedingung, text, zusatz = '') { console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); if (!bedingung) fehler += 1; } function abschnitt(titel) { console.log(`\n${titel}`); } function docker(argumente, zusatz = {}) { return spawnSync('docker', argumente, { encoding: 'utf8', ...zusatz }); } function imContainer(...argumente) { const ergebnis = docker(['exec', NAME, ...argumente]); return { ende: ergebnis.status, ausgabe: `${ergebnis.stdout}${ergebnis.stderr}` }; } let browser = null; function aufraeumen() { // -v nimmt das namenlose Volume mit – sonst bleibt bei jedem Lauf eins liegen. docker(['rm', '-fv', NAME], { stdio: 'ignore' }); docker(['rmi', ABBILD], { stdio: 'ignore' }); } async function portBelegt() { try { await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(1000) }); return true; } catch { return false; } } async function gesund(sekunden = 60) { for (let i = 0; i < sekunden; i += 1) { try { const antwort = await fetch(`${BASIS}/api/gesundheit`); if (antwort.ok) return true; } catch { /* noch nicht da */ } await new Promise((r) => setTimeout(r, 1000)); } return false; } async function anmelden(email, passwort) { const kontext = await browser.newContext({ locale: 'de-DE' }); const seite = await kontext.newPage(); seite.setDefaultTimeout(30000); await seite.goto(`${BASIS}/anmelden`); await seite.fill('#email', email); await seite.fill('#passwort', passwort); await seite.click('button[type=submit]'); const angekommen = await seite .waitForURL(`${BASIS}/`, { timeout: 15000 }) .then(() => true) .catch(() => false); await kontext.close(); return angekommen; } /** Ein Anmeldeversuch, der die angezeigte Meldung zurückgibt. */ async function meldungNachAnmeldung(email, passwort) { const kontext = await browser.newContext({ locale: 'de-DE' }); const seite = await kontext.newPage(); seite.setDefaultTimeout(30000); await seite.goto(`${BASIS}/anmelden`); await seite.fill('#email', email); await seite.fill('#passwort', passwort); await seite.click('button[type=submit]'); const text = await seite .locator('.hinweis-fehler') .innerText({ timeout: 15000 }) .catch(() => ''); await kontext.close(); return text; } async function main() { if (docker(['info'], { stdio: 'ignore' }).status !== 0) { console.error('Docker ist nicht erreichbar.'); process.exit(2); } if (await portBelegt()) { console.error(`Auf Port ${PORT} antwortet schon etwas. Anderen Port über LERNBAR_CONTAINER_PORT setzen.`); process.exit(2); } // --- C1 Das Abbild entsteht ---------------------------------------------- abschnitt('C1 · Das Abbild entsteht und startet'); console.log(' … baue das Abbild, das dauert beim ersten Mal einige Minuten'); const bau = docker(['build', '-t', ABBILD, '.'], { stdio: ['ignore', 'ignore', 'pipe'] }); pruefe(bau.status === 0, 'Das Abbild lässt sich bauen', bau.status === 0 ? '' : bau.stderr.split('\n').slice(-5).join(' ')); if (bau.status !== 0) return; const start = docker(['run', '-d', '--name', NAME, '-p', `127.0.0.1:${PORT}:3000`, ABBILD]); pruefe(start.status === 0, 'Der Container startet'); if (start.status !== 0) return; pruefe(await gesund(), 'Die Anwendung meldet sich gesund'); pruefe(imContainer('id', '-un').ausgabe.trim() === 'node', 'Sie läuft nicht als root'); // --- C2 Das erste Konto --------------------------------------------------- abschnitt('C2 · Das erste Konto entsteht im Container'); const werkzeuge = imContainer('ls', '/app/werkzeuge').ausgabe; pruefe(werkzeuge.includes('konto.js'), 'Das Kontowerkzeug liegt im Abbild'); const anlegen = imContainer( 'node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw, ); pruefe(anlegen.ende === 0 && anlegen.ausgabe.includes('Konto angelegt'), 'Der Aufruf aus der README legt das Konto an', anlegen.ausgabe.split('\n')[0]); browser = await chromium.launch(); pruefe(await anmelden(AUSBILDER.email, AUSBILDER.pw), 'Der Ausbilder meldet sich mit diesem Konto an'); pruefe(!(await anmelden(AUSBILDER.email, 'falsch-falsch')), 'Ein falsches Passwort wird abgewiesen'); // Derselbe Aufruf ist der Rettungsweg, wenn das einzige Ausbilderkonto sein // Passwort vergessen hat. const neu = imContainer('node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email); const neuesPw = neu.ausgabe.match(/Passwort: (\S+)/)?.[1]; pruefe(neu.ende === 0 && neu.ausgabe.includes('gab es schon') && neuesPw, 'Ein zweiter Aufruf setzt das Passwort neu'); pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung'); pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr'); // --- C3 Bremse gegen Passwortraten ---------------------------------------- abschnitt('C3 · Bremse gegen Passwortraten'); // Eine Adresse, die es nicht gibt: Die Sperre muss genauso greifen, sonst // verriete sie, welche Konten existieren. const unbekannt = 'niemand@betrieb.test'; for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(unbekannt, `geraten-${i}`); const gesperrt = await meldungNachAnmeldung(unbekannt, 'geraten-6'); pruefe(gesperrt.includes('Zu viele Fehlversuche'), 'Nach fünf Fehlversuchen wird gesperrt – auch für eine unbekannte Adresse', gesperrt); pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Ein anderes Konto ist davon nicht betroffen'); for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(AUSBILDER.email, `geraten-${i}`); pruefe(!(await anmelden(AUSBILDER.email, neuesPw)), 'Während der Sperre kommt auch das richtige Passwort nicht durch'); // --- C4 Sicherung im Container ------------------------------------------- abschnitt('C4 · Sicherung im Container'); const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen'); pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1)); const archive = imContainer('sh', '-c', 'ls /daten/sicherungen/lernbar-*.tar.gz').ausgabe; pruefe(/lernbar-\d{8}-\d{6}\.tar\.gz/.test(archive), 'Das Archiv liegt im Volume'); } try { await main(); } catch (e) { console.error(e); fehler += 1; } finally { if (browser) await browser.close(); aufraeumen(); } console.log(fehler === 0 ? '\n✓ Containerabnahme bestanden.' : `\n✗ ${fehler} Prüfung(en) nicht erfüllt.`); process.exit(fehler === 0 ? 0 : 1);