diff --git a/README.md b/README.md index 0749f8d..aba03ca 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# KIBU – Schritte 1 bis 3 +# KIBU – Kinder-Betreuung Online Lokale Web-App für die Kinderbetreuung mit Next.js, TypeScript und einer echten PostgreSQL-Datenbank. Keine externen Dienste, Fonts oder Bild-Uploads. Kinderkarten verwenden Initialen. @@ -99,16 +99,60 @@ docker compose up -d --wait Das benannte Volume `kibu_kibu_postgres` erhält die Daten bei Neustarts. **Nicht** `docker compose down -v` verwenden, wenn Daten erhalten bleiben sollen. Regelmäßige verschlüsselte Backups sind vor realem Einsatz erforderlich. +## Testinstanz leeren oder neu befuellen + +**Achtung: unwiderruflich, nur fuer lokale Testdatenbanken!** Vorher die App +beenden. Beide Befehle loeschen alle Kinder, Wochen-/Tagesplaene, Anwesenheit, +Abholberechtigungen, Alleingehregeln, den gesamten Audit-Verlauf, +Aufnahmerunden, Anmeldungen, Limits, Betreuungskonten und Sitzungen. +Eigene Slots werden durch die vier aktiven Standardslots ersetzt. +Schema, angewendete Migrationen und die lokale `.env` bleiben erhalten. +Der Audit-Loeschschutz wird nur innerhalb der Reset-Transaktion kurz aufgehoben; +bei einem Fehler wird die gesamte Leerung zurueckgerollt. + +```powershell +# Nur leeren, noch ohne neuen Zugang oder Demokinder: +npm run db:clear -- --confirm-reset + +# Leeren, neuen Zugang erstellen und 12 fiktive Kinder mit Verlauf anlegen: +npm run db:reset -- --confirm-reset +``` + +Ohne `--confirm-reset` wird nichts geloescht. Remote-Datenbankadressen und +PostgreSQL-Verwaltungsdatenbanken werden abgelehnt. Die Adresse allein ersetzt +keine Pruefung des Ziels: auch eine lokale Portweiterleitung darf nicht auf +Produktivdaten zeigen. + +`db:reset` fuehrt nach dem Leeren die vorhandenen Befehle `db:setup` und +`db:seed` aus. Ein neues zufaelliges Passwort erscheint im Terminal; wenn in +`.env` `BOOTSTRAP_EMAIL`/`BOOTSTRAP_PASSWORD` gesetzt sind, werden diese benutzt. +Alte Passwoerter und Sitzungen funktionieren danach nicht mehr. Die Demodaten +umfassen Kinder, Wochenplaene, Tagesabweichungen, Kontakte, Alleingehregeln und +zwei Wochen Anwesenheitsverlauf; Aufnahmerunden bleiben leer. + +Nach `db:clear` bei Bedarf `npm run db:setup` und `npm run db:seed` einzeln +ausfuehren. `db:seed` allein ist weiterhin nicht destruktiv und ueberschreibt +keine vorhandenen Kinder. Schlaegt die Neueinrichtung nach erfolgreichem Leeren +fehl, bleibt die Instanz leer bzw. teilweise eingerichtet; Ursache beheben und +`db:setup`/`db:seed` erneut ausfuehren. Anschliessend die App wieder starten. + ## Prüfen ```powershell npm test npm run test:db +npm run test:reset npm run build ``` `test:db` benötigt eine eingerichtete Datenbank. Eigene Kinder-/Audit-Testdatensätze werden innerhalb von Transaktionen geprüft und vollständig zurückgerollt. Der Paralleltest verwendet eine eigene kurzlebige Aufnahmerunde und entfernt diese samt Anmeldungen im Anschluss. Bestehende Demodaten bleiben erhalten. Geprüft werden unter anderem Korrekturen, Versionskonflikte, Vorher-/Nachher-Protokollierung, unveränderliches Audit-Log, Berechtigungen, Slots, Anmeldungen, konkurrierende Auslosungen und einmalige Übernahme. Unit-Tests prüfen exakte Fenstergrenzen und die gleichberechtigten Auswahlpfade kleiner Ziehungen. `npm run typecheck` prüft TypeScript ohne Build. +`test:reset` erstellt eine eigene zufaellig benannte lokale Testdatenbank und +entfernt ausschliesslich diese wieder. Der Datenbankzugang benoetigt dafuer +`CREATEDB` (beim lokalen Compose-Zugang vorhanden). Der Test prueft Schutzabfragen, +vollstaendige Leerung, Standardslots, erneute Demo-Befuellung, neue Zugangsdaten +und den nach dem Reset wieder aktiven Audit-Loeschschutz. + ## Betriebsgrenze Diese Version ist für den lokalen Testbetrieb gedacht, nicht zur unveränderten Veröffentlichung im Internet. Entwicklungs- und Produktionsserver binden standardmäßig nur an `127.0.0.1`. Für einen echten Einsatz mit Kinderdaten sind unter anderem HTTPS, datenschutzgerechte Betriebs-/Löschkonzepte einschließlich Audit-Daten, Backup/Restore und individuelle Betreuungszugänge erforderlich. Bei HTTPS `SESSION_SECURE=true` setzen. Der erste Zugang ist kein öffentliches Standardpasswort. diff --git a/package.json b/package.json index ac711aa..3e1e3ae 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "configure": "node scripts/configure.mjs", "db:setup": "tsx scripts/setup.ts", "db:seed": "tsx scripts/seed-demo.ts", + "db:clear": "tsx scripts/reset-instance.ts clear", + "db:reset": "tsx scripts/reset-instance.ts demo", "test": "tsx --test tests/unit.test.ts", "test:db": "tsx --test tests/database.test.ts", + "test:reset": "tsx --test tests/reset.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/reset-instance.ts b/scripts/reset-instance.ts new file mode 100644 index 0000000..529167c --- /dev/null +++ b/scripts/reset-instance.ts @@ -0,0 +1,69 @@ +import "dotenv/config"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { db } from "../src/lib/db"; + +async function reset() { + const [mode, confirmation, ...extra] = process.argv.slice(2); + if (!["clear", "demo"].includes(mode) || confirmation !== "--confirm-reset" || extra.length) { + throw new Error("Aufruf: npm run db:clear|db:reset -- --confirm-reset. Alle Instanzdaten und Zugaenge werden geloescht!"); + } + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL fehlt. Zuerst npm run configure ausfuehren."); + const target = new URL(process.env.DATABASE_URL); + if (!["postgres:", "postgresql:"].includes(target.protocol) + || !["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) + || !target.pathname.slice(1) || ["/postgres", "/template0", "/template1"].includes(target.pathname)) { + throw new Error("Reset nur fuer eine ausdruecklich konfigurierte lokale Anwendungsdatenbank erlaubt."); + } + if (mode === "demo" && process.env.BOOTSTRAP_PASSWORD !== undefined && process.env.BOOTSTRAP_PASSWORD.length < 12) { + throw new Error("BOOTSTRAP_PASSWORD muss mindestens 12 Zeichen haben. Es wurde nichts geloescht."); + } + console.log(`ACHTUNG: Alle Instanzdaten einschliesslich Konten, Sitzungen und Audit-Verlauf in ${target.hostname}:${target.port || "5432"}${target.pathname} werden geloescht.`); + const client = await db.connect(); + try { + await client.query("BEGIN"); + await client.query("SET LOCAL lock_timeout = '5s'"); + await client.query("SELECT pg_advisory_xact_lock(724139002)"); + await client.query("ALTER TABLE public.audit_log DISABLE TRIGGER audit_immutable"); + await client.query(`TRUNCATE TABLE + public.applications, public.admission_rounds, public.application_limits, + public.attendance, public.day_overrides, public.weekly_schedules, + public.pickup_contacts, public.alone_rules, public.children, public.audit_log, + public.sessions, public.login_attempts, public.staff, public.pickup_slots + RESTART IDENTITY`); + await client.query("ALTER SEQUENCE public.attendance_revision RESTART WITH 1"); + await client.query("ALTER TABLE public.audit_log ENABLE TRIGGER audit_immutable"); + await client.query(`INSERT INTO public.pickup_slots(starts_at, ends_at) VALUES + ('14:00', '14:15'), ('15:00', '15:15'), ('16:00', '16:15'), ('17:00', '17:15')`); + await client.query("COMMIT"); + console.log("Instanzdaten geloescht. Schema, Migrationen und vier Standardslots sind bereit."); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + if (mode === "clear") { + console.log("Keine Konten vorhanden. Neuer Zugang: npm run db:setup. Demodaten: npm run db:seed."); + return; + } + const tsx = createRequire(import.meta.url).resolve("tsx/cli"); + for (const script of ["setup.ts", "seed-demo.ts"]) { + const result = spawnSync(process.execPath, [tsx, fileURLToPath(new URL(script, import.meta.url))], { + cwd: fileURLToPath(new URL("../", import.meta.url)), + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${script} fehlgeschlagen. Daten wurden bereits geleert; db:setup und db:seed nach Fehlerbehebung erneut ausfuehren.`); + } + } + console.log("Demo-Instanz bereit. Mit dem neu ausgegebenen Zugang anmelden; alte Sitzungen sind ungueltig."); +} + +reset().catch((error: unknown) => { + console.error("Instanz-Reset fehlgeschlagen:", error); + process.exitCode = 1; +}).finally(() => db.end()); diff --git a/tests/reset.test.ts b/tests/reset.test.ts new file mode 100644 index 0000000..e3c64d3 --- /dev/null +++ b/tests/reset.test.ts @@ -0,0 +1,97 @@ +import "dotenv/config"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { Pool } from "pg"; +import { verifyPassword } from "../src/lib/password"; + +test("Instanz-Reset: Schutzabfragen, vollstaendige Leerung, Demodaten und erneuter Reset", async () => { + assert.ok(process.env.DATABASE_URL, "DATABASE_URL fehlt."); + const target = new URL(process.env.DATABASE_URL); + assert.ok(["localhost", "127.0.0.1", "[::1]"].includes(target.hostname), "Nur lokal testen."); + const name = `kibu_reset_test_${randomBytes(8).toString("hex")}`; + const admin = new Pool({ connectionString: target.toString() }); + target.pathname = `/${name}`; + const pool = new Pool({ connectionString: target.toString() }); + const tsx = createRequire(import.meta.url).resolve("tsx/cli"); + const password = "test-only-reset-password"; + const env = { ...process.env, DATABASE_URL: target.toString(), BOOTSTRAP_EMAIL: "reset@example.invalid", BOOTSTRAP_PASSWORD: password }; + const run = (script: string, args: string[] = [], overrides: Partial = {}) => + spawnSync(process.execPath, [tsx, fileURLToPath(new URL(`../scripts/${script}`, import.meta.url)), ...args], { + cwd: fileURLToPath(new URL("../", import.meta.url)), + env: { ...env, ...overrides }, + encoding: "utf8", + timeout: 30_000, + }); + const succeed = (result: ReturnType) => { + assert.ifError(result.error); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + }; + const counts = async () => (await pool.query(`SELECT + (SELECT count(*)::int FROM children) AS children, + (SELECT count(*)::int FROM staff) AS staff, + (SELECT count(*)::int FROM attendance) AS attendance, + (SELECT count(*)::int FROM audit_log) AS audit, + (SELECT count(*)::int FROM schema_migrations) AS migrations`)).rows[0]; + let created = false; + try { + await admin.query(`CREATE DATABASE "${name}"`); + created = true; + succeed(run("setup.ts")); + succeed(run("seed-demo.ts")); + const before = await counts(); + assert.equal(before.children, 12); + assert.ok(before.attendance > 0 && before.audit > 0); + const missingConfirmation = run("reset-instance.ts", ["clear"]); + assert.notEqual(missingConfirmation.status, 0); + assert.match(missingConfirmation.stderr, /confirm-reset/); + const remote = run("reset-instance.ts", ["clear", "--confirm-reset"], { + DATABASE_URL: `postgresql://localhost.example.invalid/${name}`, + }); + assert.notEqual(remote.status, 0); + assert.match(remote.stderr, /lokale Anwendungsdatenbank/); + const invalidPassword = run("reset-instance.ts", ["demo", "--confirm-reset"], { BOOTSTRAP_PASSWORD: "short" }); + assert.notEqual(invalidPassword.status, 0); + assert.match(invalidPassword.stderr, /nichts geloescht/); + assert.deepEqual(await counts(), before); + await pool.query(`INSERT INTO sessions(token_hash, staff_id, expires_at) + SELECT 'reset-test-session', id, now() + interval '1 hour' FROM staff`); + await pool.query("INSERT INTO pickup_slots(starts_at, ends_at) VALUES ('13:00', '13:15')"); + await pool.query(`INSERT INTO admission_rounds(name, code_hash, expires_on, created_by) + SELECT 'Reset-Test', 'reset-test-hash', current_date, id FROM staff`); + await pool.query(`INSERT INTO applications(round_id, first_name, last_name, class_name, parent_name, email) + SELECT id, 'Demo', 'Test', '1a', 'Kontakt', 'test@example.invalid' FROM admission_rounds`); + await pool.query("INSERT INTO application_limits VALUES ('test', 1, now())"); + await pool.query("INSERT INTO login_attempts VALUES ('test@example.invalid', 1, now())"); + succeed(run("reset-instance.ts", ["clear", "--confirm-reset"])); + assert.deepEqual(await counts(), { children: 0, staff: 0, attendance: 0, audit: 0, migrations: before.migrations }); + for (const table of ["sessions", "login_attempts", "weekly_schedules", "day_overrides", "pickup_contacts", "alone_rules", "applications", "admission_rounds", "application_limits"]) { + assert.equal((await pool.query(`SELECT count(*)::int AS count FROM ${table}`)).rows[0].count, 0, table); + } + const slots = await pool.query("SELECT starts_at::text AS start, ends_at::text AS end, active FROM pickup_slots ORDER BY starts_at"); + assert.deepEqual(slots.rows, [14, 15, 16, 17].map((hour) => ({ start: `${hour}:00:00`, end: `${hour}:15:00`, active: true }))); + await assert.rejects(pool.query("DELETE FROM audit_log"), /unveraenderlich/); + for (let iteration = 0; iteration < 2; iteration++) { + succeed(run("reset-instance.ts", ["demo", "--confirm-reset"])); + const after = await counts(); + assert.equal(after.children, 12); + assert.equal(after.staff, 1); + assert.ok(after.attendance > 0 && after.audit > 0); + assert.equal(after.migrations, before.migrations); + const account = (await pool.query("SELECT email, password_hash FROM staff")).rows[0]; + assert.equal(account.email, env.BOOTSTRAP_EMAIL); + assert.ok(await verifyPassword(password, account.password_hash)); + await assert.rejects(pool.query("DELETE FROM audit_log"), /unveraenderlich/); + } + } finally { + await pool.end(); + try { + if (created) await admin.query(`DROP DATABASE "${name}"`); + } finally { + await admin.end(); + } + } +});