diff --git a/CLAUDE.md b/CLAUDE.md index 9930a72..e33429e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,24 @@ $this->assertGreaterThan($min, $wert, 'Beschreibung'); $this->assertNotEmpty($wert, 'Beschreibung'); ``` +### E2E-Tests (Browser) + +Die PHP-Tests erreichen Routing, Views und Formular-JavaScript nicht. Dafür gibt +es Playwright-Tests in `tests/e2e/`, die im Container des e2e-runner laufen +(auf dem Host wird nichts installiert). Details: `tests/e2e/README.md`. + +```bash +docker compose exec web php scripts/e2e-user.php # einmalig: Testnutzer anlegen +tests/e2e/run.sh # alle E2E-Tests +docker compose exec web php scripts/e2e-cleanup.php --force # Reste entfernen +``` + +Fehlt Docker, der Runner oder das Image, wird mit Exit 0 übersprungen. + +**Welche Ebene wofür:** Rechen- und Model-Logik gehört in die PHP-Suite (schneller, +isoliert). In `tests/e2e/` gehört nur, was einen Browser braucht: Formularabläufe, +Redirects, gerenderte Views, JavaScript-Umrechnungen, DOM-XSS. + ## Dokumentation aktuell halten | Datei | Wann aktualisieren | diff --git a/Changes.md b/Changes.md index f09d517..39beef3 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,17 @@ --- +## 2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert + +- Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen [e2e-runner](../e2e-runner/) angebunden; auf dem Host wird dafür nichts installiert. +- **`src/Controllers/AccountController.php`:** `/accounts/new` lieferte HTTP 500. `create()` las `$_GET['from']` ohne Existenzprüfung – beim Aufruf aus dem Menü (ohne Parameter) also immer. Jahrelang war das eine stille PHP-Warning; seit der Fehler-Härtung (#142) wandelt der `set_error_handler` Warnings in Exceptions um, seitdem war die Seite tot. Jetzt mit `?? ''` abgesichert. +- **`src/Controllers/AssetController.php`, `src/Views/assets/form.php`:** `/assets/new` lieferte aus demselben Grund HTTP 500. Die View nutzt `$testMode` und `$defaultKontoId` unterhalb des Bearbeiten-Zweigs, gesetzt wurden beide aber nur innerhalb davon. `create()` übergibt jetzt `testMode`, die View initialisiert beide Werte am Kopf. +- **`tests/e2e/`** (neu): 44 Playwright-Tests. `smoke.spec.js` prüft, dass alle Hauptseiten ohne Serverfehler rendern (dieser Lauf fand die beiden 500er), dazu 404-Verhalten, Login-Zwang und CSV-Export. `beleg-buchung.spec.js` deckt Upload → Buchungsmaske → gespeicherte Buchung ab, insbesondere den Netto/Brutto-Umschalter (#137), dessen Umrechnung erst im submit-Handler passiert und serverseitig nicht prüfbar ist. `rechnung-export.spec.js` prüft Entwurf → Finalisieren → PDF- und XML-Export. `bank-import.spec.js` sichert die Vorschau samt XSS-Regression (#133) ab, die vollständig im Browser aufgebaut wird. +- **`scripts/e2e-user.php`, `scripts/e2e-cleanup.php`** (neu): Legen den Testnutzer idempotent an bzw. entfernen liegengebliebene, mit `E2E-TEST` markierte Testdaten. Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten und rühren ausschließlich ihre eigenen Datensätze an. +- **`tests/77_BelegLoeschsperreTest.php`** (neu): Sichert ab, dass `Beleg::delete()` einen Beleg mit Buchungen und einen festgeschriebenen Beleg verweigert und dabei die Buchungen unangetastet lässt. Bewusst als PHP-Test – reine Model-Logik gehört nicht in den Browser. +- **`tests/67_BelegXmlXsltXssTest.php`:** Der Test legte ein Belegverzeichnis an und löschte am Ende nur die Datei, nicht das Verzeichnis. Die Transaktion rollt die Belegnummer zurück, das Dateisystem aber nicht – und weil die Suite über `docker compose exec` als *root* läuft, blieb ein root-eigenes Verzeichnis zurück. Bekam später ein echter Upload dieselbe Belegnummer, scheiterte `move_uploaded_file()` mit „Permission denied" und die Seite antwortete mit HTTP 500. Das traf nicht nur Tests, sondern den normalen Beleg-Upload nach jedem lokalen Testlauf. Der Test räumt das Verzeichnis jetzt mit ab. +- Fehlt Docker, das Runner-Projekt oder das Playwright-Image, überspringt `tests/e2e/run.sh` mit Exit 0 und einer Warnung. Die E2E-Tests bleiben damit optional und blockieren keine Umgebung ohne Runner. + ## 2026-08-01 – Belege blieben nach dem Umzug nach data/ unauffindbar - Beim Umzug der Nutzdaten nach `data/` (#150) wurden die Belege mit `mv uploads/2026 data/belege/` verschoben. Existierte im Ziel bereits ein Jahresverzeichnis – etwa weil vorher schon ein Container gelaufen war –, scheiterte `mv` (bzw. legte den Baum als `data/belege/2026/2026` ab). Weil alle Fehler mit `2>/dev/null || true` unterdrückt wurden, meldete die Migration trotzdem Erfolg: In der Oberfläche waren die Belege danach nicht mehr auffindbar, obwohl die Datenbankeinträge unverändert waren. diff --git a/README.md b/README.md index 6f8ab8a..8341c17 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,21 @@ Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten | [Datenbankstruktur](docs/datenbankstruktur.md) | Tabellen, Spalten, Fremdschlüssel, GoBD-Mechanismen | | [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln | | [Kontenrahmen](docs/kontenrahmen.md) | Vollständige SKR03-Kontenliste | +| [E2E-Tests](tests/e2e/README.md) | Browser-Tests mit Playwright über den e2e-runner | + +--- + +## Tests + +```bash +docker compose exec web php tests/run.php # PHP-Suite (Models, Services, Berechnungen) +tests/e2e/run.sh # Browser-Tests (Routing, Views, Formular-JS) +``` + +Die PHP-Suite läuft ohne weitere Voraussetzungen. Die E2E-Tests brauchen Docker, +das [e2e-runner-Projekt](../e2e-runner/) und einen Testnutzer +(`docker compose exec web php scripts/e2e-user.php`); fehlt etwas davon, werden +sie mit einer Warnung übersprungen. Details in [tests/e2e/README.md](tests/e2e/README.md). --- diff --git a/scripts/e2e-cleanup.php b/scripts/e2e-cleanup.php new file mode 100644 index 0000000..c35c507 --- /dev/null +++ b/scripts/e2e-cleanup.php @@ -0,0 +1,105 @@ +#!/usr/bin/env php + 0, 'rechnungen' => 0]; +$fehler = []; + +foreach ($rechnungen as $r) { + try { + Invoice::delete((int)$r['id']); + $geloescht['rechnungen']++; + } catch (\Throwable $e) { + $fehler[] = "Rechnung #{$r['id']}: " . $e->getMessage(); + } +} + +foreach ($belege as $b) { + $id = (int)$b['id']; + // Buchungen zuerst – sonst verweigert Beleg::delete() die Löschung. + Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$id]); + Database::execute('UPDATE belege SET locked = 0 WHERE id = ?', [$id]); + + if (Beleg::delete($id)) { + $geloescht['belege']++; + } else { + $fehler[] = "Beleg #{$id} konnte nicht gelöscht werden."; + } +} + +echo "\nGelöscht: {$geloescht['belege']} Beleg(e), {$geloescht['rechnungen']} Rechnung(en).\n"; + +if ($fehler) { + echo "Nicht gelöscht:\n"; + foreach ($fehler as $f) { + echo " - {$f}\n"; + } + exit(1); +} diff --git a/scripts/e2e-user.php b/scripts/e2e-user.php new file mode 100644 index 0000000..a7520ec --- /dev/null +++ b/scripts/e2e-user.php @@ -0,0 +1,84 @@ +#!/usr/bin/env php + + * docker compose exec web php scripts/e2e-user.php --delete + * + * Die Defaults entsprechen tests/e2e/helpers/env.js. + */ + +$projectRoot = dirname(__DIR__); +if (!file_exists($projectRoot . '/vendor/autoload.php')) { + fwrite(STDERR, "Fehler: vendor/autoload.php nicht gefunden. Bitte 'composer install' ausführen.\n"); + exit(1); +} + +require $projectRoot . '/vendor/autoload.php'; +\Steuerkram\Config::load(); + +use Steuerkram\Database; +use Steuerkram\Models\User; + +const E2E_DEFAULT_USER = 'e2e'; +const E2E_DEFAULT_PASSWORD = 'e2e-test-Pass123!'; + +$args = array_slice($argv, 1); +$delete = in_array('--delete', $args, true); +$args = array_values(array_filter($args, static fn(string $a): bool => $a !== '--delete')); + +$username = $args[0] ?? E2E_DEFAULT_USER; +$password = $args[1] ?? E2E_DEFAULT_PASSWORD; + +$existing = User::findByUsername($username); + +if ($delete) { + if (!$existing) { + echo "Testnutzer \"{$username}\" existiert nicht – nichts zu tun.\n"; + exit(0); + } + User::delete((int)$existing['id']); + echo "Testnutzer \"{$username}\" gelöscht.\n"; + exit(0); +} + +if ($existing) { + $id = (int)$existing['id']; + User::update($id, $username, $password); + User::setActive($id, true); + User::resetFailedLogins($id); + echo "Testnutzer \"{$username}\" aktualisiert (Passwort neu gesetzt, aktiv, entsperrt).\n"; +} else { + User::create($username, $password); + $created = User::findByUsername($username); + if (!$created) { + fwrite(STDERR, "Fehler: Testnutzer \"{$username}\" konnte nicht angelegt werden.\n"); + exit(1); + } + echo "Testnutzer \"{$username}\" angelegt.\n"; +} + +// Gegenprobe: Das Passwort muss zum gespeicherten Hash passen, sonst läuft der +// E2E-Login in den Throttle und sperrt den Account. +$check = User::findByUsername($username); +if (!$check || !password_verify($password, $check['password_hash'])) { + fwrite(STDERR, "Fehler: Passwortprüfung für \"{$username}\" fehlgeschlagen.\n"); + exit(1); +} + +$anzahl = (int)(Database::fetchOne('SELECT COUNT(*) AS n FROM users')['n'] ?? 0); +echo "Benutzer in der Datenbank: {$anzahl}\n"; +echo "E2E-Tests starten mit: tests/e2e/run.sh\n"; diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php index a139995..77e2b28 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -141,7 +141,7 @@ class AccountController extends BaseController public function create(array $params): void { - $back = $_GET['from'] === 'settings' ? '/settings' : '/accounts'; + $back = ($_GET['from'] ?? '') === 'settings' ? '/settings' : '/accounts'; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Konto anlegen', diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index cbc6c65..7b64997 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -62,6 +62,7 @@ class AssetController extends BaseController 'action' => '/assets', 'anlagearten' => Asset::anlagearten(), 'scheduleRows' => [], + 'testMode' => Setting::isTestMode(), ]), ]); } diff --git a/src/Views/assets/form.php b/src/Views/assets/form.php index 9908d6c..651a46c 100644 --- a/src/Views/assets/form.php +++ b/src/Views/assets/form.php @@ -11,6 +11,11 @@ $centFlag = !empty($asset['cent_im_ersten_jahr']); $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true; $bkHints = $bookingHints ?? []; +// Beide Werte werden im Skriptteil unterhalb des Bearbeiten-Zweigs verwendet, +// gesetzt aber nur dort. Beim Anlegen müssen sie trotzdem definiert sein. +$defaultKontoId = 0; +$testMode = $testMode ?? false; + $conflictBadge = function(string $field, string $label) use ($bkHints, $h): string { if (!isset($bkHints[$field])) return ''; $val = $h($bkHints[$field]); diff --git a/src/version.txt b/src/version.txt index e5c403d..eefae6e 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260801 +1-260802 diff --git a/tests/67_BelegXmlXsltXssTest.php b/tests/67_BelegXmlXsltXssTest.php index fa43c2a..22cefd7 100644 --- a/tests/67_BelegXmlXsltXssTest.php +++ b/tests/67_BelegXmlXsltXssTest.php @@ -89,6 +89,12 @@ class BelegXmlXsltXssTest extends TestCase 'Der Content-Disposition-Header nutzt die ermittelte Disposition (nicht fest inline)' ); + // Datei UND Verzeichnis entfernen: Die Transaktion rollt die Belegnummer + // zurück, das Dateisystem aber nicht. Ein zurückbleibendes Verzeichnis + // gehört – weil die Suite via `docker compose exec` als root läuft – dem + // Nutzer root und blockiert später den echten Upload mit derselben + // Nummer (move_uploaded_file: Permission denied → HTTP 500). @unlink($absPath); + @rmdir($absDir); } } diff --git a/tests/77_BelegLoeschsperreTest.php b/tests/77_BelegLoeschsperreTest.php new file mode 100644 index 0000000..6f04931 --- /dev/null +++ b/tests/77_BelegLoeschsperreTest.php @@ -0,0 +1,103 @@ +findAccountId('4930'); + $zahlungsId = $this->findAccountId('1200'); + $this->assertNotEmpty($kategorieId, 'Kategoriekonto 4930 vorhanden'); + $this->assertNotEmpty($zahlungsId, 'Zahlungskonto 1200 vorhanden'); + + // ── Beleg ohne Buchung: löschbar ────────────────────────────── + $freierBelegId = $this->createBeleg('E2E-frei'); + $this->assert( + \Steuerkram\Models\Beleg::delete($freierBelegId) === true, + 'Beleg ohne Buchungen lässt sich löschen' + ); + $this->assert( + \Steuerkram\Models\Beleg::find($freierBelegId) === false, + 'Gelöschter Beleg ist nicht mehr auffindbar' + ); + + // ── Beleg mit Buchung: gesperrt ─────────────────────────────── + $gebuchterBelegId = $this->createBeleg('E2E-gebucht'); + \Steuerkram\Models\Booking::create([ + 'beleg_id' => $gebuchterBelegId, + 'buchungstyp' => 'Ausgabe', + 'buchungs_datum' => date('Y-m-d'), + 'kategorie_konto_id' => $kategorieId, + 'zahlungskonto_id' => $zahlungsId, + 'betrag_netto' => 42.00, + 'ust_satz' => 19, + 'buchungstext' => 'Löschsperre-Test', + ]); + + $anzahl = \Steuerkram\Database::fetchOne( + 'SELECT COUNT(*) AS n FROM bookings WHERE beleg_id = ?', + [$gebuchterBelegId] + ); + $this->assertGreaterThan(0, (int)$anzahl['n'], 'Buchung wurde angelegt'); + + $this->assert( + \Steuerkram\Models\Beleg::delete($gebuchterBelegId) === false, + 'Beleg mit Buchungen lässt sich NICHT löschen' + ); + $this->assertNotEmpty( + \Steuerkram\Models\Beleg::find($gebuchterBelegId), + 'Gebuchter Beleg existiert nach dem Löschversuch weiterhin' + ); + + $nachher = \Steuerkram\Database::fetchOne( + 'SELECT COUNT(*) AS n FROM bookings WHERE beleg_id = ?', + [$gebuchterBelegId] + ); + $this->assertEquals( + (int)$anzahl['n'], + (int)$nachher['n'], + 'Die Buchungen des Belegs bleiben unangetastet' + ); + + // ── Gesperrter Beleg ohne Buchung: ebenfalls gesperrt ───────── + $lockedBelegId = $this->createBeleg('E2E-locked'); + \Steuerkram\Database::execute('UPDATE belege SET locked = 1 WHERE id = ?', [$lockedBelegId]); + $this->assert( + \Steuerkram\Models\Beleg::delete($lockedBelegId) === false, + 'Festgeschriebener Beleg lässt sich NICHT löschen' + ); + $this->assertNotEmpty( + \Steuerkram\Models\Beleg::find($lockedBelegId), + 'Festgeschriebener Beleg existiert weiterhin' + ); + } + + /** Legt einen Beleg ohne Datei an (storage_path zeigt bewusst ins Leere). */ + private function createBeleg(string $marker): int + { + $belegNr = \Steuerkram\Models\Beleg::generateBelegNr(); + + return \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => $belegNr, + 'filename' => $marker . '.pdf', + 'storage_path' => date('Y') . '/' . $marker . '/original.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr . $marker), + 'belegtyp' => 'Eingangsrechnung', + 'beleg_datum' => date('Y-m-d'), + 'notes' => $marker, + ]); + } +} diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..403dd62 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +.auth/ diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..6596f9f --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,123 @@ +# Steuerkram — E2E-Tests (Playwright) + +Browser-Tests für die Ebene, die die PHP-Testsuite nicht erreicht: Routing, +Controller, Views und das JavaScript in den Formularen. + +Sie laufen **im Container** des generischen +[e2e-runner-Projekts](../../../e2e-runner/) (`~/projects/e2e-runner/`) — auf dem +Host wird nichts installiert (kein Node, kein Browser, kein npm). + +## Warum diese Ebene + +Die PHP-Suite (`tests/NN_*.php`) prüft Models und Services und ist dort gründlich. +Sie ruft aber nie einen Controller über HTTP auf. Genau dort saßen die zuletzt +aufgetretenen Fehler: + +- **`/accounts/new` und `/assets/new` lieferten HTTP 500** — gefunden vom ersten + Smoke-Lauf. Ursache: undefinierte Variablen, die seit der Fehler-Härtung + (`Fix #142`) keine stille Warning mehr sind, sondern eine Exception. +- **Netto/Brutto-Umschalter** (`Fix #137`): Ein submit-Handler rechnet den + eingegebenen Bruttowert vor dem Absenden in Netto um. Serverseitig ist nur das + Ergebnis sichtbar — eine kaputte Umrechnung fiele nie auf. +- **DOM-XSS in der Bankimport-Vorschau** (`Fix #133`): Die Vorschau wird + vollständig im Browser aus einer `fetch`-Antwort gebaut. + +## Voraussetzungen + +1. **Steuerkram läuft** unter `http://localhost:8080` (`docker compose up -d`). +2. **Testnutzer existiert** (einmalig, idempotent wiederholbar): + + ```bash + docker compose exec web php scripts/e2e-user.php + ``` + + Legt `e2e` / `e2e-test-Pass123!` an bzw. setzt Passwort und Sperrstatus + zurück. Abweichende Zugangsdaten als Argumente oder per `E2E_USER` / + `E2E_PASSWORD` (Defaults: `helpers/env.js`). Entfernen mit `--delete`. +3. **Testmodus aktiv** — sonst lassen sich Testbuchungen und -rechnungen nicht + wieder löschen (Einstellungen → Testmodus). + +## Ausführen + +```bash +tests/e2e/run.sh +``` + +Das Skript prüft vorab, ob Docker, das e2e-runner-Projekt (Pfad überschreibbar +via `E2E_RUNNER_DIR`) und das Playwright-Image vorhanden sind. Fehlt etwas, wird +**nur gewarnt und übersprungen** (Exit 0) — die E2E-Tests sind optional und +blockieren keine Umgebung ohne Runner. + +Andere Zielinstanz: `BASE_URL=http://host.docker.internal:9000 tests/e2e/run.sh` + +Einzelne Datei oder UI-Modus zum Debuggen: + +```bash +cd ~/projects/e2e-runner +TEST_DIR=~/projects/Steuerkram/tests/e2e docker compose run --rm playwright \ + sh -c "npm ci && npx playwright test smoke.spec.js" + +# UI-Modus, danach http://localhost:8077 öffnen: +TEST_DIR=~/projects/Steuerkram/tests/e2e docker compose run --rm --service-ports playwright-ui +``` + +HTML-Report, Screenshots und Traces landen in `playwright-report/` und +`test-results/`. + +## Umgang mit Testdaten + +Die Tests laufen gegen eine Instanz mit **echten Arbeitsdaten**. Deshalb gilt: + +- Sie legen ausschließlich eigene Datensätze an, markiert mit `E2E-TEST--` + (`helpers/app.js`, `uniqueTag`), und räumen sie im `afterEach` wieder ab. +- Bestehende Belege, Buchungen und Rechnungen werden nie verändert oder gelöscht. +- Der Bankimport-Test bricht **vor** dem Bestätigen ab — die Vorschau allein legt + keine Transaktionen an. + +Reste nach einem abgebrochenen Lauf entfernen: + +```bash +docker compose exec web php scripts/e2e-cleanup.php # nur anzeigen +docker compose exec web php scripts/e2e-cleanup.php --force # löschen +``` + +### Nicht umkehrbare Nebenwirkung + +`rechnung-export.spec.js` finalisiert eine Rechnung und **verbraucht dabei eine +Nummer aus dem laufenden Nummernkreis**. Die Rechnung selbst wird wieder gelöscht, +die Nummer bleibt vergeben (GoBD — eine Rücknahme wäre auch nicht wünschenswert). +Wer das vermeiden will, lässt diesen Test weg: + +```bash +... npx playwright test --grep-invert "Finalisieren" +``` + +## Aufbau + +| Datei | Inhalt | +|---|---| +| `specs/smoke.spec.js` | Alle Hauptseiten rendern ohne Serverfehler; 404, Login-Zwang, CSV-Export | +| `specs/beleg-buchung.spec.js` | Upload → Buchungsmaske → gespeicherte Buchung, inkl. Netto/Brutto-Umschalter | +| `specs/rechnung-export.spec.js` | Entwurf → Finalisieren → PDF- und XML-Export (XRechnung 3.0) | +| `specs/bank-import.spec.js` | Bankimport-Vorschau inkl. XSS-Regression | +| `helpers/app.js` | Upload, Aufräumen, Betragsparsing, `gotoOk` | +| `helpers/env.js` | Zugangsdaten und Basis-URL | +| `global-setup.js` | Einmaliger Login, legt `.auth/user.json` an | + +## Fallstricke + +- **Submit-Buttons immer auf ihr Formular scopen.** In der Sidebar steht das + Logout-Formular (`layout/nav.php`); ein ungescopetes + `button[type="submit"]` trifft dessen Button zuerst und beendet die Session — + alle folgenden Tests laufen dann gegen die Login-Seite. +- **Reihenfolge in der Buchungsmaske:** Die Auswahl des Kategorie-Kontos ruft + `updateAutoUst()` und setzt den USt-Satz passend zum Steuercode. Den Satz + deshalb **nach** dem Konto setzen. +- **Ein Redirect auf `/login` endet mit Status 200.** `gotoOk()` prüft deshalb + zusätzlich den Pfad — sonst schlagen Tests mit irreführenden Meldungen fehl. +- **Belegverzeichnisse aus PHP-Testläufen:** `docker compose exec web php tests/run.php` + läuft als *root* und kann in `data/belege//` ein Verzeichnis anlegen, das + root gehört, während die Transaktion die Belegnummer zurückrollt. Trifft später + ein echter Upload auf dieselbe Nummer, scheitert `move_uploaded_file()` mit + HTTP 500. Solche Leichen finden: + `find data/belege -maxdepth 2 -user root` diff --git a/tests/e2e/global-setup.js b/tests/e2e/global-setup.js new file mode 100644 index 0000000..98628ac --- /dev/null +++ b/tests/e2e/global-setup.js @@ -0,0 +1,48 @@ +// @ts-check +// Einmalig per Login-Formular anmelden und den Session-Zustand (Cookies) als +// storageState speichern — alle Tests starten damit eingeloggt. +// +// Steuerkram sperrt einen Account nach mehreren Fehlversuchen (Login-Throttle, +// siehe tests/53_LoginThrottleTest.php). Ein falsches Passwort hier würde den +// Testnutzer also sperren – deshalb bricht das Setup mit einer klaren Meldung ab, +// statt es erneut zu versuchen. +const { chromium } = require('@playwright/test') +const { BASE_URL, E2E_USER, E2E_PASSWORD } = require('./helpers/env') + +module.exports = async () => { + const browser = await chromium.launch() + const page = await browser.newPage({ baseURL: BASE_URL }) + + await page.goto('/login') + + // Ist noch kein Benutzer angelegt, zeigt Steuerkram die Ersteinrichtung. + // Dann liefe der Login ins Leere – lieber sofort verständlich abbrechen. + if (await page.locator('#password2').count() > 0) { + await browser.close() + throw new Error( + `Die Instanz unter ${BASE_URL} ist im Ersteinrichtungs-Modus (kein Benutzer vorhanden). ` + + 'Bitte zuerst einen Benutzer anlegen, dann scripts/e2e-user.php ausführen.' + ) + } + + await page.fill('#username', E2E_USER) + await page.fill('#password', E2E_PASSWORD) + await page.click('form[action="/login"] button[type="submit"]') + + // Erfolgreicher Login = Redirect weg von /login (Dashboard). + try { + await page.waitForURL(url => !url.pathname.startsWith('/login'), { timeout: 15000 }) + } catch { + const fehler = await page.locator('.flash-error').first().textContent().catch(() => null) + await browser.close() + throw new Error( + `Login als "${E2E_USER}" auf ${BASE_URL} fehlgeschlagen` + + (fehler ? `: ${fehler.trim()}` : '.') + + '\nTestnutzer anlegen: docker compose exec web php scripts/e2e-user.php' + + '\n(Abweichende Zugangsdaten via E2E_USER / E2E_PASSWORD.)' + ) + } + + await page.context().storageState({ path: '.auth/user.json' }) + await browser.close() +} diff --git a/tests/e2e/helpers/app.js b/tests/e2e/helpers/app.js new file mode 100644 index 0000000..b12c034 --- /dev/null +++ b/tests/e2e/helpers/app.js @@ -0,0 +1,182 @@ +// @ts-check +// Gemeinsame Hilfsfunktionen für die Steuerkram-E2E-Tests. +// +// Grundsatz: Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten. +// Sie legen deshalb ausschließlich eigene, eindeutig markierte Datensätze an +// (siehe uniqueTag) und räumen diese im Teardown wieder ab. Bestehende Daten +// werden nie verändert oder gelöscht. + +const { expect } = require('@playwright/test') + +/** Präfix, an dem sich Testdaten jederzeit erkennen und aufräumen lassen. */ +const TAG_PREFIX = 'E2E-TEST' + +let counter = 0 + +/** Eindeutiger Marker pro Datensatz, z.B. "E2E-TEST-1754130000000-3". */ +function uniqueTag(suffix = '') { + counter += 1 + return `${TAG_PREFIX}-${Date.now()}-${counter}${suffix ? '-' + suffix : ''}` +} + +/** + * Minimales, gültiges PDF. Der Marker landet im Dateiinhalt, damit jeder + * Upload einen eigenen SHA-256-Hash hat — sonst greift die Dublettenprüfung. + */ +function pdfBuffer(marker) { + const pdf = [ + '%PDF-1.4', + '1 0 obj<>endobj', + '2 0 obj<>endobj', + '3 0 obj<>endobj', + `% ${marker}`, + 'trailer<>', + '%%EOF', + ].join('\n') + return Buffer.from(pdf, 'latin1') +} + +/** + * Prüft, dass keine generische 500-Seite ausgeliefert wurde. + * public/index.php zeigt bei unbehandelten Fehlern genau diesen Text — + * ein reiner Statuscode-Check würde ihn nicht zuverlässig erkennen. + */ +async function expectNoServerError(page) { + await expect(page.locator('body')).not.toContainText('Ein Fehler ist aufgetreten') +} + +/** + * Öffnet einen Pfad und stellt sicher, dass er ohne Serverfehler rendert. + * + * Wichtig: Eine abgelaufene Session leitet auf /login um, was mit Status 200 + * endet. Ohne die URL-Prüfung liefe jeder Test danach gegen die Login-Seite und + * schlüge mit einer irreführenden Meldung fehl. + */ +async function gotoOk(page, path) { + const response = await page.goto(path) + expect(response, `Keine Antwort für ${path}`).not.toBeNull() + expect(response.status(), `Unerwarteter Status für ${path}`).toBeLessThan(400) + expect( + new URL(page.url()).pathname, + `Statt ${path} kam die Login-Seite — die Session ist nicht (mehr) gültig.` + ).not.toBe('/login') + await expectNoServerError(page) + return response +} + +/** window.confirm() automatisch bestätigen (Löschdialoge, siehe app.js). */ +function acceptDialogs(page) { + page.on('dialog', dialog => dialog.accept().catch(() => {})) +} + +/** + * Lädt einen Beleg hoch und liefert dessen ID. + * Nach dem Upload landet man auf der Detailseite /belege/{id}. + */ +async function uploadBeleg(page, { marker, belegtyp = 'Eingangsrechnung', datum = null, rechnungsNr = null } = {}) { + const tag = marker || uniqueTag() + + await gotoOk(page, '/belege/upload') + await page.setInputFiles('#beleg_file', { + name: `${tag}.pdf`, + mimeType: 'application/pdf', + buffer: pdfBuffer(tag), + }) + await page.selectOption('#belegtyp', belegtyp) + if (datum) await page.fill('#beleg_datum', datum) + await page.fill('#rechnungs_nr', rechnungsNr || tag) + await page.fill('#notes', tag) + + // Der Klick muss auf das Upload-Formular gescopet sein: In der Sidebar steht + // das Logout-Formular (layout/nav.php) und dessen Submit-Button käme im DOM + // zuerst — ein ungescopeter Treffer würde die Session beenden. + await Promise.all([ + page.waitForURL(/\/belege\/\d+/), + page.click('form[action="/belege/upload"] button[type="submit"]'), + ]) + + const id = belegIdFromUrl(page.url()) + expect(id, `Beleg-ID nicht aus URL ermittelbar: ${page.url()}`).toBeGreaterThan(0) + return { id, tag } +} + +function belegIdFromUrl(url) { + const m = url.match(/\/belege\/(\d+)/) + return m ? Number(m[1]) : 0 +} + +/** + * Räumt einen Beleg ab: erst dessen Buchungen (nur im Testmodus löschbar), + * dann den Beleg selbst. Fehler werden geschluckt — der Teardown darf einen + * bereits gescheiterten Test nicht zusätzlich rot färben. + */ +async function cleanupBeleg(page, belegId) { + if (!belegId) return + acceptDialogs(page) + + try { + await page.goto(`/belege/${belegId}`) + + // Buchungen des Belegs entfernen (Formulare mit action /bookings/{id}/delete). + for (;;) { + const form = page.locator('form[action^="/bookings/"][action$="/delete"]').first() + if (await form.count() === 0) break + await form.locator('button[type="submit"]').click() + await page.waitForLoadState('networkidle') + if (!page.url().includes(`/belege/${belegId}`)) { + await page.goto(`/belege/${belegId}`) + } + } + + const delForm = page.locator(`form[action="/belege/${belegId}/delete"]`).first() + if (await delForm.count() > 0) { + await delForm.locator('button[type="submit"]').click() + await page.waitForLoadState('networkidle') + } + } catch { + // Aufräumen ist best effort. + } +} + +/** + * Löscht eine Rechnung (nur im Testmodus erlaubt). + * + * Das Lösch-Formular steht in der Listenansicht (invoices/list.php), nicht in + * der Detailansicht — der Aufräumschritt muss deshalb über /invoices gehen. + */ +async function cleanupInvoice(page, invoiceId) { + if (!invoiceId) return + acceptDialogs(page) + try { + await page.goto('/invoices') + const form = page.locator(`form[action="/invoices/${invoiceId}/delete"]`).first() + if (await form.count() > 0) { + await form.locator('button[type="submit"]').click() + await page.waitForLoadState('networkidle') + } + } catch { + // Aufräumen ist best effort. + } +} + +/** Betrag "1.234,56 €" → 1234.56 */ +function parseMoney(text) { + if (!text) return NaN + const m = String(text).match(/-?[\d.]+,\d{2}/) + if (!m) return NaN + return Number(m[0].replace(/\./g, '').replace(',', '.')) +} + +module.exports = { + TAG_PREFIX, + uniqueTag, + pdfBuffer, + expectNoServerError, + gotoOk, + acceptDialogs, + uploadBeleg, + belegIdFromUrl, + cleanupBeleg, + cleanupInvoice, + parseMoney, +} diff --git a/tests/e2e/helpers/env.js b/tests/e2e/helpers/env.js new file mode 100644 index 0000000..c1d542f --- /dev/null +++ b/tests/e2e/helpers/env.js @@ -0,0 +1,8 @@ +// @ts-check +// Zentrale Umgebungs-Defaults. Der Testnutzer muss in der Zielinstanz existieren — +// Anlage siehe README.md in diesem Verzeichnis (scripts/e2e-user.php). +module.exports = { + BASE_URL: process.env.BASE_URL || 'http://host.docker.internal:8080', + E2E_USER: process.env.E2E_USER || 'e2e', + E2E_PASSWORD: process.env.E2E_PASSWORD || 'e2e-test-Pass123!', +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..3277da7 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "steuerkram-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "steuerkram-e2e", + "devDependencies": { + "@playwright/test": "1.61.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..1292d3d --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,8 @@ +{ + "name": "steuerkram-e2e", + "private": true, + "description": "E2E-Tests für Steuerkram — laufen im e2e-runner-Container (Playwright-Version muss zum Image-Tag passen)", + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000..7b9dd8a --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,21 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test') + +module.exports = defineConfig({ + testDir: './specs', + globalSetup: require.resolve('./global-setup'), + // Die Tests mutieren dieselbe Steuerkram-Instanz → seriell statt parallel. + workers: 1, + // Kein retry: Ein flackernder Test soll auffallen, nicht weggeglättet werden. + retries: 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: process.env.BASE_URL || 'http://host.docker.internal:8080', + storageState: '.auth/user.json', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..d950fa2 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# E2E-Tests über den generischen e2e-runner ausführen. +# Die Tests sind OPTIONAL: Fehlt die Infrastruktur (Docker, Runner-Projekt, +# gepulltes Playwright-Image), wird nur eine Warnung ausgegeben und mit +# Exit-Code 0 übersprungen — es wird nichts nachinstalliert oder gepullt. +set -eu + +TEST_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RUNNER_DIR=${E2E_RUNNER_DIR:-$HOME/projects/e2e-runner} +BASE_URL=${BASE_URL:-http://host.docker.internal:8080} + +skip() { + echo "WARNUNG: $1 — E2E-Tests werden übersprungen." >&2 + exit 0 +} + +command -v docker >/dev/null 2>&1 || skip "Docker nicht gefunden" +docker info >/dev/null 2>&1 || skip "Docker-Daemon nicht erreichbar" +[ -f "$RUNNER_DIR/docker-compose.yml" ] || skip "e2e-runner-Projekt nicht gefunden ($RUNNER_DIR)" + +IMAGE=$(sed -n 's/^ *image: *//p' "$RUNNER_DIR/docker-compose.yml" | head -n1) +[ -n "$IMAGE" ] || skip "Playwright-Image im Compose-File nicht ermittelbar" +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || skip "Playwright-Image $IMAGE nicht vorhanden (docker pull $IMAGE)" + +# Zugangsdaten des Testnutzers durchreichen. Der Runner ist projekt-agnostisch +# und kennt nur BASE_URL – die Defaults stehen in helpers/env.js. +cd "$RUNNER_DIR" +TEST_DIR="$TEST_DIR" BASE_URL="$BASE_URL" exec docker compose run --rm \ + ${E2E_USER:+-e E2E_USER="$E2E_USER"} \ + ${E2E_PASSWORD:+-e E2E_PASSWORD="$E2E_PASSWORD"} \ + playwright diff --git a/tests/e2e/specs/bank-import.spec.js b/tests/e2e/specs/bank-import.spec.js new file mode 100644 index 0000000..323d886 --- /dev/null +++ b/tests/e2e/specs/bank-import.spec.js @@ -0,0 +1,106 @@ +// @ts-check +// Hotspot: Bankimport-Vorschau. +// +// Die Vorschau wird vollständig im Browser aufgebaut: Die Datei geht per fetch() +// an /bank/detect-format, das Ergebnis wird per innerHTML in die Seite geschrieben +// (src/Views/bank/upload.php). Genau hier saß das DOM-XSS aus Issue #133 — ein +// serverseitiger Test kann diesen Pfad grundsätzlich nicht erreichen. +// +// Diese Tests brechen bewusst VOR dem Bestätigen ab: Die Vorschau allein legt +// keine Banktransaktionen an, der Datenbestand bleibt unangetastet. + +const { test, expect } = require('@playwright/test') +const { uniqueTag, gotoOk } = require('../helpers/app') + +/** CSV im Comdirect-Format: 4 zu überspringende Zeilen, dann Header, dann Daten. */ +function comdirectCsv(buchungstext, betrag = '-119,00') { + return [ + 'Umsätze Girokonto;', + '"Zeitraum:";"30 Tage";', + '"Neuer Kontostand";"1.234,56 EUR";', + '', + '"Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR";', + `"15.05.2026";"15.05.2026";"Lastschrift";"${buchungstext}";"${betrag}";`, + '"14.05.2026";"14.05.2026";"Gutschrift";"Zahlungseingang Kunde";"500,00";', + '', + ].join('\n') +} + +/** Datei in die Upload-Fläche legen und auf die fertige Formaterkennung warten. */ +async function datenVorschauLaden(page, csvInhalt, dateiname = 'e2e-bank.csv') { + await gotoOk(page, '/bank/upload') + await page.setInputFiles('#bank-dnd-input', { + name: dateiname, + mimeType: 'text/csv', + buffer: Buffer.from(csvInhalt, 'utf-8'), + }) + // Die Vorschau erscheint erst, wenn der fetch auf /bank/detect-format durch ist. + await expect(page.locator('#preview-firstrow')).toBeVisible({ timeout: 15000 }) +} + +test.describe('Bankimport-Vorschau', () => { + test('Vorschau zeigt die erste Datenzeile und aktiviert den Import-Button', async ({ page }) => { + const tag = uniqueTag() + await datenVorschauLaden(page, comdirectCsv(`Lieferant ${tag}`)) + + await expect(page.locator('#preview-firstrow')).toContainText(tag) + await expect(page.locator('#preview-firstrow')).toContainText('15.05.2026') + await expect(page.locator('#map-wert')).toContainText('-119,00') + + // Erkanntes Format muss gesetzt und der Import damit möglich sein. + await expect(page.locator('#bank-upload-btn')).toBeEnabled() + expect(await page.locator('#format_id').inputValue()).not.toBe('') + }) + + test('Skriptcode im Buchungstext wird nicht ausgeführt (Regression #133)', async ({ page }) => { + const marker = uniqueTag('XSS') + // Zwei Vektoren: ein Element mit Error-Handler und ein direktes Script-Tag. + const payload = `