Files
lernbar/scripts/pruefungen/container.mjs
Florian WildeandClaude Opus 5.5 31d7779f45 Bremse gegen Passwortraten
Nach fuenf Fehlversuchen wird eine E-Mail-Adresse gesperrt, erst eine
Minute, dann doppelt so lang, hoechstens 15 Minuten. Gezaehlt wird je
Adresse, auch fuer unbekannte - sonst verriete die Sperre, welche Konten
es gibt. Waehrend der Sperre kommt auch das richtige Passwort nicht durch.

Jeder Versuch zaehlt sofort und wird bei Erfolg zurueckgenommen. Zwischen
Sperrpruefung und Zaehlen liegt damit kein await; gleichzeitige Anfragen
kommen so nicht alle an der Sperre vorbei.

Der Zaehler liegt im Speicher des einen Prozesses. Ein Neustart setzt ihn
zurueck - der Notausgang, falls sich das einzige Ausbilderkonto aussperrt.
Beim Aufraeumen wird eine laufende Sperre nie verdraengt.

Die Containerabnahme prueft die Sperre im laufenden Abbild. Das
Abnahmeprotokoll ist gegen diesen Stand neu erzeugt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:25:04 +02:00

200 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);