Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbf1ac4de | ||
|
|
adb93a722f | ||
|
|
6181ec4bd7 |
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@@ -26,6 +26,29 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [1.0.1] – 2026-10-03
|
||||
|
||||
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Im Docker-Abbild ließ sich kein erstes Konto anlegen.** Die README nannte
|
||||
`node werkzeuge/konto.js`, das Abbild enthielt aber nur die Sicherungsskripte,
|
||||
und `tsx` gibt es dort nicht. Ein frisch aufgesetzter Server stand vor einer
|
||||
Anmeldeseite ohne Konto. Das Kontowerkzeug wird jetzt beim Bau mit esbuild zu
|
||||
einer Datei gebündelt (`npm run werkzeuge:bauen`) und liegt unter
|
||||
`/app/werkzeuge/konto.js`
|
||||
- **Windows-Pfade in Dateinamen von Anhängen** wurden unter Linux nicht
|
||||
abgetrennt – aus `C:\Users\x\foto.jpg` wurde `C:Usersxfoto.jpg`. Nur die
|
||||
Anzeige war betroffen, gespeichert wird unter der UUID. Der Test dazu war
|
||||
unter Windows grün und unter Linux rot
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- `npm run abnahme:container` – Abnahmelauf gegen das gebaute Docker-Abbild:
|
||||
Lebenszeichen, Betrieb ohne root, erstes Konto, Anmeldung, Passwort neu
|
||||
setzen, Sicherung im Volume
|
||||
|
||||
## [1.0.0] – 2026-09-11
|
||||
|
||||
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
|
||||
|
||||
+5
-1
@@ -24,6 +24,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Das Kontowerkzeug als eine einzige JS-Datei – im Abbild gibt es kein tsx.
|
||||
# Die nativen Module bleiben draußen, sie bringt die Standalone-Ausgabe mit.
|
||||
RUN npm run werkzeuge:bauen
|
||||
|
||||
# --- Laufzeit -------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION} AS laufzeit
|
||||
@@ -51,8 +54,9 @@ COPY --from=bau /app/public ./public
|
||||
COPY --from=bau /app/content ./content
|
||||
COPY --from=bau /app/src/lib/db/migrationen ./migrationen
|
||||
|
||||
# Werkzeuge für Sicherung und Wiederherstellung im Container.
|
||||
# Werkzeuge für Sicherung, Wiederherstellung und das erste Konto.
|
||||
COPY --from=bau /app/scripts/im-container ./werkzeuge
|
||||
COPY --from=bau /app/build/werkzeuge/konto.js ./werkzeuge/konto.js
|
||||
RUN chmod +x ./werkzeuge/*.sh
|
||||
|
||||
RUN mkdir -p /daten && chown -R node:node /daten /app
|
||||
|
||||
@@ -39,14 +39,13 @@ Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto
|
||||
anlegen:
|
||||
|
||||
```bash
|
||||
docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar?
|
||||
docker compose exec lernbar sh -c \
|
||||
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
|
||||
docker compose exec lernbar node werkzeuge/konto.js \
|
||||
--name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder
|
||||
```
|
||||
|
||||
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom
|
||||
> Entwicklungsrechner aus:
|
||||
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
|
||||
Ohne `--passwort` wird eines erzeugt und ausgegeben. Gibt es die Adresse
|
||||
schon, setzt derselbe Aufruf das Passwort neu – das ist zugleich der Weg, wenn
|
||||
das einzige Ausbilderkonto sein Passwort vergessen hat.
|
||||
|
||||
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
|
||||
das Ausbildungsverhältnis mit Beginndatum einrichten.
|
||||
@@ -244,6 +243,7 @@ npm run typecheck # TypeScript
|
||||
npm run lint # ESLint
|
||||
npm run test # Vitest – Logik, die wehtut (315 Tests)
|
||||
npm run abnahme # der Abnahmelauf, siehe unten
|
||||
npm run abnahme:container # derselbe Weg gegen das Docker-Abbild
|
||||
```
|
||||
|
||||
### Der Abnahmelauf
|
||||
@@ -269,6 +269,16 @@ Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
|
||||
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
|
||||
prüfen.
|
||||
|
||||
### Der Containerlauf
|
||||
|
||||
`npm run abnahme` prüft über `next start`, betrieben wird aber der Container.
|
||||
`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem
|
||||
Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über
|
||||
`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sicherung im Container.
|
||||
Danach werden Container, Volume und Abbild wieder entfernt.
|
||||
|
||||
Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`).
|
||||
|
||||
### Einzelne Zusagen
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lernbar",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lernbar",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lernbar",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||
"engines": {
|
||||
@@ -17,11 +17,13 @@
|
||||
"e2e": "playwright test",
|
||||
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
|
||||
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
|
||||
"werkzeuge:bauen": "esbuild scripts/konto-anlegen.ts --bundle --platform=node --target=node22 --format=cjs --external:better-sqlite3 --external:@node-rs/argon2 --outfile=build/werkzeuge/konto.js",
|
||||
"backup": "bash scripts/im-container/sicherung.sh",
|
||||
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"symbole": "tsx scripts/symbole.ts",
|
||||
"abnahme": "node scripts/pruefungen/abnahme.mjs"
|
||||
"abnahme": "node scripts/pruefungen/abnahme.mjs",
|
||||
"abnahme:container": "node scripts/pruefungen/container.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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, 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;
|
||||
}
|
||||
|
||||
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 Sicherung im Container -------------------------------------------
|
||||
|
||||
abschnitt('C3 · 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);
|
||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
* im sichtbaren Text.
|
||||
*/
|
||||
|
||||
const BASIS = 'http://localhost:3311';
|
||||
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
|
||||
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
|
||||
|
||||
@@ -26,6 +26,29 @@ const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.leng
|
||||
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
|
||||
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
|
||||
|
||||
// --- Ausgangslage herstellen ----------------------------------------------
|
||||
//
|
||||
// Der entscheidende Fall ist „freigegeben, aber noch nicht abgegeben". Den gibt
|
||||
// es nur einmal je Kontrolle. Damit die Prüfung wiederholbar bleibt, wird die
|
||||
// Kontrolle „woche-01" vorher zurückgesetzt – das ist ein Testaufbau, kein Teil
|
||||
// der Anwendung, und greift deshalb ausdrücklich nur auf die lokale
|
||||
// Beispieldatenbank zu.
|
||||
const { default: Datenbank } = await import('better-sqlite3');
|
||||
const dbPfad = process.env.LERNBAR_DB ?? path.join(process.cwd(), 'daten', 'lernbar.sqlite');
|
||||
|
||||
if (fs.existsSync(dbPfad)) {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const betroffen = db.prepare("select id from kontrolle where kontrolle_id = 'woche-01'").all();
|
||||
for (const eintrag of betroffen) {
|
||||
db.prepare('delete from kontroll_antwort where kontrolle_id = ?').run(eintrag.id);
|
||||
db.prepare('delete from kontrolle where id = ?').run(eintrag.id);
|
||||
}
|
||||
db.close();
|
||||
console.log(`Ausgangslage hergestellt: ${betroffen.length} frühere Bearbeitung(en) von woche-01 entfernt.`);
|
||||
} else {
|
||||
console.log(`Keine Datenbank unter ${dbPfad} – die Ausgangslage wird nicht zurückgesetzt.`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
|
||||
|
||||
@@ -41,13 +64,19 @@ async function abmelden() {
|
||||
await seite.waitForURL(/anmelden/);
|
||||
}
|
||||
|
||||
/** Sucht im rohen HTML **und** in der RSC-Nutzlast. */
|
||||
/** Sucht im rohen HTML **und** in der Server-Nutzlast. */
|
||||
async function lecksuche(beschriftung) {
|
||||
const html = await seite.content();
|
||||
|
||||
// Die Nutzlast steht in eingebetteten Skripten, die `self.__next_f` füllen.
|
||||
// Dort **nachträglich** nachzusehen bringt nichts: Next leert das Feld, sobald
|
||||
// es verarbeitet ist – es wäre immer leer, und die Prüfung liefe ins Leere,
|
||||
// ohne dass es auffällt. Deshalb wird der Quelltext der Skripte gelesen.
|
||||
const rsc = await seite.evaluate(() =>
|
||||
// Next legt die Server-Nutzlast in self.__next_f ab – dort steht alles,
|
||||
// was der Server an den Browser geschickt hat.
|
||||
(globalThis.self?.__next_f ?? []).map((t) => (Array.isArray(t) ? String(t[1] ?? '') : '')).join(''),
|
||||
[...document.querySelectorAll('script:not([src])')]
|
||||
.map((element) => element.textContent ?? '')
|
||||
.filter((text) => text.includes('__next_f'))
|
||||
.join(''),
|
||||
);
|
||||
|
||||
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
|
||||
@@ -55,7 +84,7 @@ async function lecksuche(beschriftung) {
|
||||
|
||||
console.log(
|
||||
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
|
||||
` | RSC-Größe ${rsc.length} | "musterloesung":null ${nullFelder}x`,
|
||||
` | Nutzlast ${rsc.length} Zeichen | "musterloesung":null ${nullFelder}x`,
|
||||
);
|
||||
return treffer;
|
||||
}
|
||||
@@ -71,8 +100,17 @@ await abmelden();
|
||||
await anmelden(AUSBILDER);
|
||||
await seite.goto(`${BASIS}/kontrollen`);
|
||||
await seite.waitForSelector('.kontrollzeile');
|
||||
await seite.locator('.kontrollzeile', { hasText: 'Woche 1' }).locator('button:has-text("Freigeben")').click();
|
||||
const freigeben = seite
|
||||
.locator('.kontrollzeile', { hasText: 'Woche 1' })
|
||||
.locator('button:has-text("Freigeben")');
|
||||
if ((await freigeben.count()) > 0) {
|
||||
await freigeben.click();
|
||||
await seite.waitForTimeout(1500);
|
||||
} else {
|
||||
// Bei einem wiederholten Lauf ist sie schon freigegeben. Das ist kein
|
||||
// Grund abzubrechen – der entscheidende Fall kommt erst danach.
|
||||
console.log(' (war bereits freigegeben)');
|
||||
}
|
||||
|
||||
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
|
||||
await abmelden();
|
||||
|
||||
@@ -67,7 +67,9 @@ export function megabyte(bytes: number): string {
|
||||
* etwas verloren.
|
||||
*/
|
||||
export function saubererName(name: string): string {
|
||||
return path
|
||||
// win32.basename trennt an / und \ – der Name kommt vom Browser, nicht vom
|
||||
// Server, und ein Windows-Pfad darf auch unter Linux nicht durchrutschen.
|
||||
return path.win32
|
||||
.basename(name)
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.replace(/["\\]/g, '')
|
||||
|
||||
Reference in New Issue
Block a user