# Testinstanz und automatisierter Testlauf **Gilt für:** `https://dc-dev.mhdf.de` — eine vollständige zweite Installation des Deploymentcenters, die jederzeit gelöscht und neu aufgebaut werden darf. > **Bewusst nicht unter `public/`.** Diese Datei nennt interne Adressen und den Weg, die > Datenbank der Testinstanz zu leeren. `/docs/` auf dem Deploymentcenter ist öffentlich > erreichbar; dort gehört das nicht hin. Zugangsdaten stehen ausschließlich in > `Serverdaten.txt` und `scripts/deploy_dev_config.json` — beide sind per `.gitignore` > ausgeschlossen und werden nie hochgeladen. --- ## 1. Wozu es sie gibt — und wozu nicht | | Testinstanz `dc-dev.mhdf.de` | Test-VM (siehe [TESTUMGEBUNG.md](TESTUMGEBUNG.md)) | |---|---|---| | Prüft | **den Server**: API, Rechte, Migrationen, Zugangssperre | **den Client**: Installer, Updateagent, Oberfläche | | Datenbestand | wird bei jedem Lauf gelöscht | bleibt | | Zugriff | HTTPS + Basic-Auth | SSH | Beides zusammen deckt die Kette ab. Getrennt zu halten ist Absicht: Ein Testlauf, der Tabellen löscht, darf nicht auf derselben Maschine laufen wie ein Test, der eine Installation über Stunden beobachtet. **Was die Testinstanz nicht prüft:** die Produktion. Sie hat eigene Schlüssel, eine eigene Datenbank und **Rocket.Chat ist abgeschaltet** (`rocketchat.enabled = false`). Ein Testlauf, der echte Kanäle vollschreibt, wäre der Fehler, der am ehesten unbemerkt teuer wird. --- ## 2. Aufbau ``` dc-dev.mhdf.de Subdomain, Docroot = /deploymentcenter_dev/ auf dem FTP ├── .htaccess Sperre + Routen (erzeugt, siehe §5) ├── config/ │ ├── .htpasswd erzeugt aus scripts/deploy_dev_config.json │ ├── config.php erzeugt aus scripts/dev/config.dev.php.tmpl │ └── release-signing.pem auf der Instanz erzeugt (testctl genkey) ├── public/ src/ sql/ var/ cli/ wie in der Produktion └── testctl.php nur hier — Steuerung des Testlaufs (§6) ``` Im Repository: | Datei | Zweck | |---|---| | `scripts/deploy_dev.py` | Deployment auf die Testinstanz | | `scripts/deploy_dev_config.json` | Zugangsdaten (**nicht im Git**, Vorlage: `…example.json`) | | `scripts/dev/htaccess-prelude.conf` | der Sperrblock, der der `.htaccess` vorangestellt wird | | `scripts/dev/config.dev.php.tmpl` | Vorlage der `config.php` der Testinstanz | | `tests/server/testctl.php` | wird nach `/testctl.php` hochgeladen — **nur** von `deploy_dev.py` | | `tests/e2e/` | der Testlauf | `scripts/deploy.py` (Produktion) schließt `tests/` vollständig aus. `testctl.php` kann nicht versehentlich auf `dc.mhdf.de` landen. --- ## 3. Deployment ```bash python scripts/deploy_dev.py --dry-run ``` ```bash python scripts/deploy_dev.py ``` Überträgt nur geänderte Dateien (eigener Cache: `scripts/.deploy_dev_cache.json`). `--full` ignoriert den Cache. Drei Dinge macht das Skript anders als das Produktiv-Deployment: 1. **Die `.htaccess` wird erzeugt** — der Sperrblock aus `scripts/dev/htaccess-prelude.conf` plus die `.htaccess` des Repositories. Eine zweite gepflegte Kopie der Routen gibt es damit nicht; Änderungen an den Routen wirken auf beiden Instanzen. 2. **Die `config.php` wird erzeugt** — mit der Testdatenbank, eigenen Schlüsseln, `debug = true` und abgeschaltetem Rocket.Chat. Die Produktivkonfiguration wird nie übertragen. 3. **`testctl.php` kommt dazu.** Das Skript bricht ab, wenn die Zugangsdaten auf den Produktivserver zeigen oder `base_url` kein `dc-dev` enthält. ### Das Testtor vor der Produktion `scripts/deploy.py` (Produktion) führt seit dem 29.08.2026 **vor** jedem Upload aus: 1. `scripts/deploy_dev.py` — der Stand, der gleich hochgehen soll, kommt auf die Testinstanz 2. `tests/e2e/run.py` — der vollständige Testlauf Erst bei Rückgabewert 0 beginnt der Upload auf `dc.mhdf.de`. Schlägt etwas fehl, wird **nichts** übertragen. Die Reihenfolge ist der Punkt: Liefe der Testlauf gegen den vorhandenen Dev-Stand, prüfte er einen älteren Code als den, der gleich produktiv geht — und gäbe grünes Licht für etwas, das er nie gesehen hat. ```bash python scripts/deploy.py --ohne-test ``` Der Notausgang. Er gehört in die Hand eines Menschen, der weiß, warum er ihn nimmt — `--dry-run` läuft ohnehin ohne Testlauf, weil dabei nichts übertragen wird. --- ## 4. Testlauf ```bash python tests/e2e/run.py ``` Der Lauf setzt zuerst die Datenbank zurück, spielt alle Migrationen ein, legt einen Administrator und ein Master-Token an — und prüft dann in dieser Reihenfolge: | Gruppe | Was geprüft wird | |---|---| | `sperre` | Basic-Auth, gesperrte Verzeichnisse, Sicherheitskopfzeilen, `testctl`-Schlösser | | `kern` | Health, OpenAPI, Migration (auch: wiederholbar), 404/405/400-Verhalten | | `token` | Master-/Sub-Token, Rechteeinschränkung, keine Rechteausweitung | | `bugtracker` | melden, Idempotenz, Arbeit holen, Reservierung, Kommentar, Abschluss | | `roadmap` | Epics, Einplanung, Lesewege | | `fehlerstrom` | Ausnahmen aus Clients | | `lizenz` | Entwicklungsschlüssel, Prüfung, Rückgabe | | `watchdog` | Lebenszeichen, Ereignisse, Kennzahlen, Auswertung | | `update` | Release veröffentlichen, Update finden bzw. nicht finden, Signierschlüssel | | `setup` | Erstinstallation: Anmeldung, Katalog, Projekttoken, Monitor | | `supervisor` | Befunde melden, lesen, einstufen | Nützliche Schalter: ```bash python tests/e2e/run.py --gruppe sperre --gruppe kern ``` ```bash python tests/e2e/run.py --nur roadmap --log ``` ```bash python tests/e2e/run.py --bericht bericht.json --kein-reset ``` | Schalter | Wirkung | |---|---| | `--gruppe X` | nur diese Gruppe (mehrfach möglich) | | `--nur TEXT` | Namensfilter | | `--kein-reset` | Datenbestand behalten (dann `master_token` in der Konfiguration hinterlegen) | | `--log` | bei Fehlschlägen das Serverlog der Instanz anhängen | | `--stopp` | beim ersten Fehlschlag abbrechen | | `--bericht DATEI` | Ergebnisse als JSON | | `--liste` | nur auflisten, nichts ausführen | | `--ipv4` | IPv4 erzwingen (siehe §8) | **Rückgabewert:** `0` alles bestanden, `1` Fehlschläge, `2` der Lauf sagt nichts aus. Damit lässt er sich ohne Auswertung der Ausgabe in eine Pipeline hängen. `2` kommt in zwei Fällen: der Lauf konnte gar nicht starten (Zurücksetzen fehlgeschlagen, kein Testfall passt zur Auswahl) — oder es wurde **mehr übersprungen als geprüft**. Der zweite Fall trifft fast nur gefilterte Läufe. Übersprungen wird, wenn eine Voraussetzung aus einem früheren Testfall fehlt, und bei `--gruppe`/`--nur` ist der oft mit weggefiltert: `ctx['freies_item']` etwa entsteht in der Gruppe `bugtracker`, wird aber in `roadmap` gebraucht. Vorher endete so ein Lauf mit „2 bestanden, 0 fehlgeschlagen, 5 übersprungen" und Rückgabewert 0 — grün, obwohl fünf von sieben Fällen nie liefen. Nichts ist dabei kaputt; geprüft wurde es aber auch nicht, und der Unterschied zwischen „kaputt" und „nicht prüfbar" darf nicht ausgerechnet am Rückgabewert verschwinden. Der Bericht führt dasselbe als `aussagekräftig: false`. ### Einen Testfall ergänzen ```python @test('bugtracker', 'Kurze Aussage, was gelten soll') def _(ctx): """Warum das wichtig ist - eine Zeile, kein Roman.""" r = ctx.client.post('/api/bugtracker/v1/report', body={...}) erwarte_erfolg(r) ctx['bug_id'] = r.feld('item_id') # steht dem nächsten Testfall zur Verfügung ``` Die Reihenfolge der Registrierung ist die Reihenfolge der Ausführung; die Importe in `run.py` legen die Reihenfolge der Dateien fest. Fehlt eine Voraussetzung (`ctx['bug_id']` wurde nie gesetzt), wird der Testfall **übersprungen** statt fälschlich rot — der Unterschied zwischen „kaputt" und „nicht prüfbar" muss im Bericht sichtbar bleiben. ### Bekannte Befunde Ein gefundener Fehler bekommt zweierlei: ein Item im Bugtracker **und** einen Testfall, der ihn festhält. Damit dieser Testfall nicht jedes Deployment blockiert, bis der Fehler behoben ist, steht er in `tests/e2e/bekannte_befunde.json`: ```json "supervisor/Antwortumschlag von observe bleibt erkennbar": { "item": 59, "seit": "2026-08-29", "grund": "observe ueberschreibt das Umschlagfeld 'status' mit dem Zustand des Befundes." } ``` Der Fall läuft weiter mit, erscheint als `BEK` und im Bericht — aber der Lauf bleibt grün. Die beiden Alternativen wären schlechter: den Testfall löschen heißt, den Befund zu vergessen; mit einem dauerhaft roten Lauf leben heißt, dass niemand mehr hinsieht, wenn etwas Neues rot wird. **Ein Eintrag ohne Item-Nummer ist eine Ausrede, kein Befund.** Besteht der Fall wieder, meldet der Lauf das ausdrücklich (`NEU!`) — dann gehört der Eintrag heraus und das Item auf `resolved`. --- ## 5. Die Zugangssperre Die Testinstanz läuft mit `debug = true`, mit Beispieldaten und mit einem Datenbestand, den jeder Testlauf löschen darf. Nichts davon gehört ins offene Netz. * **Basic-Auth vor allem**, was ein Mensch im Browser aufruft — WebUI, `/docs/`, `install_db.php`, `testctl.php`. * **Ausnahme für `/api/`**, wenn der Aufruf bereits ein Token der Anwendung mitbringt (`X-Agent-Token`, `X-Master-Token`, `X-License-Key`, `X-Watchdog-Key` oder `Authorization: Bearer …`). Ohne diese Ausnahme müsste jedes SDK zusätzlich zu seinem Token die Basic-Auth-Daten kennen. Die Testinstanz prüfte dann einen Aufrufweg, den es auf `dc.mhdf.de` nicht gibt. Die Ausnahme öffnet nichts, was in der Produktion nicht ohnehin offen wäre — geprüft wird das Token weiterhin von `ApiAuth`/`TokenManager`. Ein Token-Header allein schaltet weder WebUI noch Dokumentation frei; die Gruppe `sperre` prüft genau das bei jedem Lauf. `config/.htpasswd` liegt im Webroot, ist aber durch die zweite `.htaccess` in `config/` gesperrt (`Require all denied`) — die Datei muss dort liegen, weil `AuthUserFile` einen absoluten Pfad braucht. --- ## 6. `testctl.php` Steuert die Instanz. Erreichbar nur mit Basic-Auth **und** Shared Key, und nur auf einer Instanz mit `testing.enabled = true` und `app.env = 'test'`. ```bash curl -s -u deploy:PASSWORT -H "X-Master-Token: SHARED_KEY" \ "https://dc-dev.mhdf.de/testctl.php?action=status" ``` | Aktion | Wirkung | |---|---| | `status` | Tabellen, Zeilenzahlen, angewendete Migrationen, Debug-Zustand | | `reset` | löschen, migrieren, Administrator und Master-Token anlegen (braucht `confirm`) | | `wipe` | nur löschen (braucht `confirm` = Name der Datenbank) | | `migrate` | Migrationen nachziehen | | `genkey` | Signierschlüssel für den Updateservice erzeugen | | `log` | die letzten Zeilen aus `var/log/` | | `clearlog` | Logdateien leeren | `reset` und `wipe` verlangen `confirm` mit dem Namen der Datenbank. Damit lässt sich der Aufruf nicht versehentlich gegen die falsche Instanz absetzen. --- ## 7. Wenn etwas fehlschlägt 1. `python tests/e2e/run.py --nur "" --log` — der einzelne Fall mit Serverlog. 2. `--kein-reset` dazu, wenn der Datenbestand des fehlgeschlagenen Laufs erhalten bleiben soll. 3. Die Instanz steht im Browser offen: `https://dc-dev.mhdf.de` (Basic-Auth, dann Anmeldung mit dem Administrator aus `Serverdaten.txt`). Ein Fehlschlag ist **nicht** automatisch ein Fehler der Anwendung — er kann auch heißen, dass der Testfall einen Vertrag beschreibt, den es so nicht mehr gibt. Beides ist ein Befund; die Frage ist nur, welche Seite geändert wird. --- ## 8. Bekannte Eigenheiten der Umgebung **IPv6.** Alle Hoster-Adressen haben einen AAAA-Eintrag. Ist von einem Arbeitsplatz aus keine IPv6-Route vorhanden, läuft in Python **jeder** Aufruf zuerst 21 Sekunden in einen TCP-Timeout, bevor er auf IPv4 zurückfällt (`curl` tut das sofort, Python nicht). Der Testlauf prüft IPv6 deshalb einmal beim Start und schaltet bei Bedarf für den ganzen Lauf auf IPv4 um; die gewählte Familie steht in der Kopfzeile der Ausgabe. Betrifft nur den Testlauf — für andere Python-Werkzeuge gegen `dc.mhdf.de` gilt dasselbe. **PHP 8.2.33, FPM**, `memory_limit` 512 MB, `max_execution_time` 120 s. Die Produktion kann davon abweichen; wo eine Laufzeitgrenze zum Befund gehört, muss sie mitgeprüft werden. **Datenbank `watchdog`** auf `lwrs.your-database.de` — vorher die Datenbank des alten Watchdogs, jetzt ausschließlich Testbestand. Sie wird bei jedem Lauf mit `reset` geleert.