Nur noch, was zum Installieren und Betreiben von TESM noetig ist
Entfernt: Tests, Architekturdokumentation und die Serverhaelfte des Lizenzprotokolls. Beides liegt im Entwicklungsrepository alientim/TESM-DEV. Das Lizenzprotokoll ist geteilt. TESM braucht nur die gemeinsame Haelfte (tesm-licensing): Lizenzen verifizieren, Status bewerten, Anfragen stellen, Antworten pruefen. Ausstellen, erneuern, Antworten signieren und Schluesselerzeugung liegen jetzt in tesm-licensing-server und damit ausschliesslich beim Lizenzserver -- ein Client soll den Code zum Ausstellen nicht einmal mitbringen. Nachgeprueft: kein Modul von TESM oder tesm-core importiert eine der verschobenen Funktionen. install.sh installiert entsprechend je Anwendung nur die noetigen Pakete und bricht mit klarer Meldung ab, wenn die verlangte Anwendung nicht im Baum liegt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,26 @@ Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben,
|
|||||||
Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und
|
Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und
|
||||||
ein SSH-Terminal im Browser.
|
ein SSH-Terminal im Browser.
|
||||||
|
|
||||||
Der Lizenzserver liegt in einem eigenen Repository:
|
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von
|
||||||
[alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license).
|
TESM gebraucht wird** -- nichts darueber hinaus:
|
||||||
Beide teilen sich den Kern (`packages/tesm-core`) und das Lizenzprotokoll
|
|
||||||
(`packages/tesm-licensing`); diese Pakete sind hier mit enthalten, damit sich
|
```
|
||||||
das Repository allein installieren laesst.
|
apps/tesm die Anwendung
|
||||||
|
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
||||||
|
packages/tesm-licensing Lizenzprotokoll, Clientseite
|
||||||
|
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
||||||
|
docs/ Betrieb und Sicherheit
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Serverhaelfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen,
|
||||||
|
erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen
|
||||||
|
und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal
|
||||||
|
mitbringen.
|
||||||
|
|
||||||
|
| Wo sonst noch | |
|
||||||
|
|---|---|
|
||||||
|
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver |
|
||||||
|
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||||
|
|
||||||
## Zweige
|
## Zweige
|
||||||
|
|
||||||
@@ -20,7 +35,7 @@ das Repository allein installieren laesst.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation auf einem Server
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install.sh --app tesm --https
|
sudo bash deploy/install.sh --app tesm --https
|
||||||
@@ -64,41 +79,7 @@ Rollback.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Release bauen
|
## Betrieb von der Kommandozeile
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag -a v2.0.0 -m "TESM 2.0.0" && git push origin v2.0.0
|
|
||||||
bash deploy/release.sh v2.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Heraus kommt `tesm-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem
|
|
||||||
Repository, nicht aus einer festen Zeichenkette.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entwicklung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv && . .venv/bin/activate
|
|
||||||
pip install -e packages/tesm-licensing -e packages/tesm-core -e apps/tesm
|
|
||||||
pip install pytest
|
|
||||||
pytest tests -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Ohne gesetzte Umgebungsvariablen legt die Anwendung ihre Daten unter
|
|
||||||
`instance/tesm/` im Arbeitsverzeichnis an -- kein Zugriff auf `/srv` oder
|
|
||||||
`/var/log` noetig.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TESM_DEBUG=1 flask --app tesm run
|
|
||||||
```
|
|
||||||
|
|
||||||
Im Debug-Modus wird `COOKIE_SECURE` abgeschaltet, sonst kaeme das
|
|
||||||
Sitzungscookie ueber `http://127.0.0.1` nie an.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verwaltung von der Kommandozeile
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm tesm-admin status # Zustand der Installation
|
sudo -u tesm tesm-admin status # Zustand der Installation
|
||||||
@@ -114,13 +95,13 @@ Ein Export des Vorgaengers laesst sich uebersetzen und dann ueber den normalen
|
|||||||
Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration:
|
Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-run
|
sudo -u tesm tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im
|
`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im
|
||||||
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
|
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
|
||||||
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten, Rechte,
|
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten,
|
||||||
Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
||||||
|
|
||||||
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase`
|
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase`
|
||||||
existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und
|
existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und
|
||||||
@@ -128,30 +109,27 @@ Prozessliste.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Release bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v2.0.0 -m "TESM 2.0.0" && git push origin v2.0.0
|
||||||
|
bash deploy/release.sh v2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Heraus kommt `tesm-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem
|
||||||
|
Repository, nicht aus einer festen Zeichenkette.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Aufbau, Schnitte und die Gruende dafuer |
|
|
||||||
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
|
|
||||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche |
|
||||||
|
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
|
||||||
|
|
||||||
---
|
Aufbau und Entwurfsentscheidungen stehen in `docs/ARCHITEKTUR.md` im
|
||||||
|
Entwicklungsrepository, ebenso die Tests.
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Dazu ein Skript, das **auf dem Zielhost gegen die laufende Installation**
|
|
||||||
spricht -- ueber nginx, gunicorn und echtes TLS. Es wird nicht mitinstalliert
|
|
||||||
(`install.sh` laesst `tests/` aus); bei Bedarf dorthin kopieren. Jeder Lauf legt
|
|
||||||
eigene Datensaetze an, ist also beliebig oft wiederholbar:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tests/e2e_smoke.py https://host admin 'Passwort'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,10 +145,10 @@ anders geloest ist. Die wichtigsten:
|
|||||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
||||||
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
||||||
falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update.
|
falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update.
|
||||||
|
* **Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie
|
||||||
|
Kea. Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim
|
||||||
|
naechsten Start des Dienstes.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung.
|
* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung.
|
||||||
* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer
|
* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer
|
||||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||||
* **Jede Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie Kea.
|
|
||||||
Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim naechsten
|
|
||||||
Start des Dienstes.
|
|
||||||
|
|||||||
+10
-5
@@ -229,11 +229,16 @@ fi
|
|||||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||||
"$ROOT/venv/bin/python3" -m pip install --quiet \
|
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||||
"$ROOT/packages/tesm-licensing" \
|
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||||
"$ROOT/packages/tesm-core" \
|
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||||
"$ROOT/apps/$APP" \
|
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||||
gunicorn
|
if [[ "$APP" == "tesm-license" ]]; then
|
||||||
|
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||||
|
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||||
|
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||||
|
fi
|
||||||
|
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
# Architektur
|
|
||||||
|
|
||||||
Zwei Anwendungen, ein gemeinsamer Kern, ein gemeinsames Lizenzprotokoll.
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/
|
|
||||||
tesm-licensing/ Lizenzformat und -protokoll (Ed25519). Keine Web-Abhaengigkeit.
|
|
||||||
tesm-core/ Der Kern: Datenbank, Rechte, Anmeldung, Sitzungen, Protokolle,
|
|
||||||
Einstellungen, Systemzugriff, Oberflaechenbausteine.
|
|
||||||
apps/
|
|
||||||
tesm/ Geraete- und Switchverwaltung (PoE, DHCP, Dateifreigaben, Wartung).
|
|
||||||
tesm-license/ Lizenzserver (Kunden, Tickets, Lizenzen, Portal).
|
|
||||||
deploy/ Helfer, sudoers, systemd, nginx, logrotate, install.sh
|
|
||||||
tests/ Unit- und Integrationstests, dazu drei E2E-Skripte fuer den Zielhost.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Warum genau diese Schnitte
|
|
||||||
|
|
||||||
**`tesm-licensing` ist ein eigenes Paket, keine kopierte Datei.**
|
|
||||||
Im Vorgaengerprojekt lag `licensing.py` zweimal im Repo -- einmal beim Client,
|
|
||||||
einmal beim Server -- mit der Auflage, beide byte-identisch zu halten. Das ist
|
|
||||||
keine Invariante, die ein Mensch dauerhaft einhaelt. Hier gibt es das Modul
|
|
||||||
einmal; beide Anwendungen installieren dasselbe Paket.
|
|
||||||
`tests/test_repo_hygiene.py` schlaegt Alarm, sobald irgendwo wieder eine Datei
|
|
||||||
namens `licensing.py` auftaucht.
|
|
||||||
|
|
||||||
**`tesm-core` enthaelt alles, was beide Anwendungen gleich machen.**
|
|
||||||
Nicht nur Technik (Datenbank, Sitzungen, CSRF), sondern auch ganze Seiten:
|
|
||||||
Benutzer- und Gruppenverwaltung, Protokollansichten, Papierkorb, Diagnose,
|
|
||||||
Netzwerk-, Webserver- und Verzeichnisdiensteinstellungen. Eine Seite, die in
|
|
||||||
beiden Anwendungen gleich aussehen soll, existiert genau einmal.
|
|
||||||
|
|
||||||
**Die Apps bringen nur ihre Fachlichkeit mit** -- plus vier Beschreibungen, die
|
|
||||||
der Kern auswertet: `permissions.py` (Rechtebaum), `navigation.py`,
|
|
||||||
`schema.py` (Migrationen), `settings_spec.py`.
|
|
||||||
|
|
||||||
## Datenbank
|
|
||||||
|
|
||||||
Rohes `sqlite3`, kein ORM. Begruendung: das Datenmodell ist klein und stabil,
|
|
||||||
die Abfragen sind lesbar, und es gibt keine Schicht, die im Fehlerfall erst
|
|
||||||
verstanden werden muss.
|
|
||||||
|
|
||||||
Die Klasse `tesm_core.db.Database` erzwingt drei Dinge:
|
|
||||||
|
|
||||||
* WAL-Modus, `foreign_keys=ON`, `busy_timeout=15000`.
|
|
||||||
* Verschachtelte Transaktionen laufen ueber SAVEPOINTs.
|
|
||||||
* Ein `connect()` **waehrend** einer offenen Schreibtransaktion loest einen
|
|
||||||
`NestedConnectionError` aus, statt in einen Deadlock zu laufen. Das ist der
|
|
||||||
strukturelle Riegel gegen die haeufigste Fehlerklasse des Vorgaengers: eine
|
|
||||||
Funktion oeffnete waehrend eines Schreibvorgangs eine zweite Verbindung, und
|
|
||||||
die Anwendung blieb stehen, bis das Zeitlimit zuschlug.
|
|
||||||
|
|
||||||
Migrationen sind versioniert nach `(komponente, version)` mit Pruefsumme. Sie
|
|
||||||
laufen ueber `split_statements()`, nicht ueber `executescript()` -- letzteres
|
|
||||||
committet implizit und zerlegt die umgebende Transaktion.
|
|
||||||
|
|
||||||
## Rechte
|
|
||||||
|
|
||||||
Ein Rechtebaum aus **Bereich -> Ressource -> Aktion**. Die Aktionen sind ein
|
|
||||||
festes Vokabular: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXECUTE`, `EXPORT`,
|
|
||||||
`SECRETS`.
|
|
||||||
|
|
||||||
* `SECRETS` und `EXPORT` sind eigene Rechte. Wer Zugangsdaten bearbeiten darf,
|
|
||||||
darf sie damit noch nicht im Klartext sehen oder ausfuehren.
|
|
||||||
* Bereiche haben einen Hauptschalter: ein abgeschalteter Bereich ist weg,
|
|
||||||
unabhaengig von Einzelrechten.
|
|
||||||
* **Keine Rechteausweitung:** niemand kann einem anderen Konto ein Recht geben,
|
|
||||||
das er selbst nicht hat (`guard_no_amplification`). Ebenso wenig laesst sich
|
|
||||||
der letzte Administrator entfernen oder sperren (`guard_last_admin`).
|
|
||||||
* Jede Pruefung ist serverseitig. Das Template blendet zusaetzlich aus, was
|
|
||||||
nicht erlaubt ist -- aber es *entscheidet* nichts. Im Vorgaenger wurden
|
|
||||||
Verzeichnisdienst und Webserver nur im Template geprueft; ein direkter POST
|
|
||||||
kam durch.
|
|
||||||
|
|
||||||
## Lizenzierung
|
|
||||||
|
|
||||||
Ed25519, mit den Haertungen, die ein Signaturprotokoll erst brauchbar machen:
|
|
||||||
|
|
||||||
* **Getrennte Signaturkontexte** fuer Lizenz, Anfrage und Antwort. Eine
|
|
||||||
Signatur aus dem einen Kontext ist im anderen wertlos.
|
|
||||||
* **Kanonisches JSON** -- die Signatur haengt nicht an der Schluesselreihenfolge.
|
|
||||||
* **Nonce gegen Wiedereinspielung**, Antwort an die Anfrage gebunden.
|
|
||||||
* **Frischefenster** gegen alte, mitgeschnittene Antworten.
|
|
||||||
* **`seq` gegen Rueckstufung:** eine aeltere Revision wird abgelehnt.
|
|
||||||
* **Aussteller-Pinning (Trust on first use).** Die erste eingespielte Lizenz
|
|
||||||
pinnt den oeffentlichen Schluessel ihres Ausstellers. Ein Wechsel verlangt
|
|
||||||
eine ausdrueckliche Bestaetigung durch einen Administrator.
|
|
||||||
* **Fingerabdruck ohne Hostnamen.** Er bildet sich aus Maschinen-ID und
|
|
||||||
DMI-UUID. Der Hostname ist bewusst **nicht** enthalten: im Vorgaenger brach
|
|
||||||
jedes Umbenennen des Rechners die Bindung.
|
|
||||||
|
|
||||||
Der Ablauf: Kunde -> Ticket -> Lizenz. Das Ticket haelt die Parameter (Typ,
|
|
||||||
Module, Laufzeit, Rolle); die Lizenz ist das signierte Ergebnis. Ein zweiter
|
|
||||||
Klick auf "Ausstellen" erzeugt keine zweite Lizenz, sondern zeigt die offene.
|
|
||||||
|
|
||||||
Aenderungen an einer laufenden Lizenz brauchen keine Neuinstallation: der
|
|
||||||
Anbieter signiert eine neue Revision, der Client holt sie beim naechsten
|
|
||||||
Heartbeat.
|
|
||||||
|
|
||||||
## Systemzugriff
|
|
||||||
|
|
||||||
Der Webprozess laeuft unprivilegiert. Root-Aktionen gehen ausschliesslich durch
|
|
||||||
`deploy/tesm-helper`: ein Skript mit fester Verbliste, Argumentpruefung gegen
|
|
||||||
Positivlisten und ohne jede Weitergabe an eine Shell. sudoers gibt genau diesen
|
|
||||||
einen Pfad frei.
|
|
||||||
|
|
||||||
Aktionen, die mehrere Schritte brauchen, sind **ein** Verb -- nicht drei.
|
|
||||||
`nginx-apply` etwa schreibt, prueft und laedt neu; scheitert `nginx -t`, stellt
|
|
||||||
es den vorherigen Stand wieder her. Getrennte Verben hatten hier eine echte
|
|
||||||
Luecke: `nginx-write` verlinkt sofort nach `sites-enabled`, und eine
|
|
||||||
anschliessend abgelehnte Datei blieb aktiviert liegen. Der laufende nginx merkte
|
|
||||||
nichts -- der naechste Reload durch irgendwen legte den ganzen Webserver lahm.
|
|
||||||
|
|
||||||
## Mehrere Installationen auf einem Host
|
|
||||||
|
|
||||||
Zwei Schluessel, mit klarer Aufgabenteilung:
|
|
||||||
|
|
||||||
* **`app_key`** (`tesm`, `tesm-license`) -- was die Anwendung *ist*. Bestimmt
|
|
||||||
Env-Praefix, Rechtebaum, Migrationsnamensraum.
|
|
||||||
* **`site_key`** (`tesm`, `tesm-opus`, ...) -- welche Installation es *ist*.
|
|
||||||
Bestimmt alles ausserhalb von `/srv/<name>`: nginx-Site, netplan-Datei,
|
|
||||||
logrotate-Regel, Zertifikatsverzeichnis und den Namen des Sitzungscookies.
|
|
||||||
|
|
||||||
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
|
|
||||||
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben -- und
|
|
||||||
beide Installationen teilten sich dasselbe Sitzungscookie, weil Cookies keine
|
|
||||||
Ports unterscheiden.
|
|
||||||
|
|
||||||
Die Adresse des Anwendungsservers (`BIND`) steht in derselben Umgebungsdatei,
|
|
||||||
aus der auch gunicorn seinen `--bind` bekommt. Sie ist zugleich das
|
|
||||||
`proxy_pass`-Ziel der erzeugten nginx-Site. Vorher war die `5000` fest im Code:
|
|
||||||
eine Instanz auf Port 5100 erzeugte damit eine Konfiguration, die auf die
|
|
||||||
*andere* Installation zeigte.
|
|
||||||
|
|
||||||
## Oberflaeche
|
|
||||||
|
|
||||||
Handgeschriebenes CSS in `@layer`n mit semantischen Tokens, drei Themen (hell,
|
|
||||||
dunkel, System). JavaScript als ES-Module, angebunden ueber
|
|
||||||
`data-behavior`-Attribute -- kein Inline-Script, kein Build-Schritt, und damit
|
|
||||||
vertraeglich mit einer strikten CSP, die `unsafe-inline` fuer Skripte nicht
|
|
||||||
erlaubt.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
| Datei | Was sie absichert |
|
|
||||||
|---|---|
|
|
||||||
| `test_licensing.py` | Format, Signaturen, Protokoll, Fingerabdruck |
|
|
||||||
| `test_core_smoke.py` | Kern: Datenbank, Migrationen, Schluesselspeicher, Sitzungen |
|
|
||||||
| `test_core_security.py` | Passwoerter, TOTP, Ratenbegrenzung, CSRF, Rechte |
|
|
||||||
| `test_webserver.py` | nginx-Vorlage, TLS, Zertifikate, Instanztrennung |
|
|
||||||
| `test_tesm_smoke.py` | TESM: Seiten, Validierung, Rechtematrix |
|
|
||||||
| `test_license_server.py` | Lizenzserver: Ausstellung, Portal, API |
|
|
||||||
| `test_repo_hygiene.py` | Zeilenenden, Geheimnisse im Repo, Helferpruefungen, sudoers |
|
|
||||||
|
|
||||||
Dazu drei Skripte, die **auf dem Zielhost gegen die echte Installation** laufen
|
|
||||||
(nginx, gunicorn, systemd, TLS -- keine Attrappen):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tests/e2e_smoke.py https://host admin 'Passwort'
|
|
||||||
python3 tests/e2e_license_server.py https://lizenz.host admin 'Passwort'
|
|
||||||
python3 tests/e2e_license.py https://lizenz.host https://host admin 'Passwort' http://127.0.0.1:5001
|
|
||||||
```
|
|
||||||
|
|
||||||
Das dritte spielt den vollstaendigen Lizenz-Lebenszyklus ueber beide
|
|
||||||
Anwendungen hinweg durch: ausstellen, herunterladen, einspielen, aktivieren,
|
|
||||||
Module pruefen, Heartbeat, Revision, Widerruf.
|
|
||||||
@@ -16,8 +16,6 @@ from .canonical import (
|
|||||||
canonical_bytes,
|
canonical_bytes,
|
||||||
decode_code,
|
decode_code,
|
||||||
encode_code,
|
encode_code,
|
||||||
generate_keypair,
|
|
||||||
public_from_private,
|
|
||||||
sign,
|
sign,
|
||||||
verify,
|
verify,
|
||||||
)
|
)
|
||||||
@@ -44,12 +42,10 @@ from .license import (
|
|||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
TRIAL_DEFAULT_DAYS,
|
TRIAL_DEFAULT_DAYS,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
issue_license,
|
|
||||||
iso,
|
iso,
|
||||||
key_id,
|
key_id,
|
||||||
license_status,
|
license_status,
|
||||||
parse_iso,
|
parse_iso,
|
||||||
resign_license,
|
|
||||||
split_bundle,
|
split_bundle,
|
||||||
utcnow,
|
utcnow,
|
||||||
verify_license,
|
verify_license,
|
||||||
@@ -62,9 +58,7 @@ from .protocol import (
|
|||||||
REQUEST_MAX_AGE_SECONDS,
|
REQUEST_MAX_AGE_SECONDS,
|
||||||
RESPONSE_STATES,
|
RESPONSE_STATES,
|
||||||
build_client_request,
|
build_client_request,
|
||||||
build_master_response,
|
|
||||||
new_nonce,
|
new_nonce,
|
||||||
verify_client_request,
|
|
||||||
verify_master_response,
|
verify_master_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,26 +94,20 @@ __all__ = [
|
|||||||
"TRIAL_DEFAULT_DAYS",
|
"TRIAL_DEFAULT_DAYS",
|
||||||
"UntrustedIssuer",
|
"UntrustedIssuer",
|
||||||
"build_client_request",
|
"build_client_request",
|
||||||
"build_master_response",
|
|
||||||
"canonical_bytes",
|
"canonical_bytes",
|
||||||
"decode_code",
|
"decode_code",
|
||||||
"encode_code",
|
"encode_code",
|
||||||
"generate_keypair",
|
|
||||||
"issue_license",
|
|
||||||
"iso",
|
"iso",
|
||||||
"key_id",
|
"key_id",
|
||||||
"license_status",
|
"license_status",
|
||||||
"machine_identity",
|
"machine_identity",
|
||||||
"new_nonce",
|
"new_nonce",
|
||||||
"parse_iso",
|
"parse_iso",
|
||||||
"public_from_private",
|
|
||||||
"resign_license",
|
|
||||||
"sign",
|
"sign",
|
||||||
"split_bundle",
|
"split_bundle",
|
||||||
"system_fingerprint",
|
"system_fingerprint",
|
||||||
"utcnow",
|
"utcnow",
|
||||||
"verify",
|
"verify",
|
||||||
"verify_client_request",
|
|
||||||
"verify_license",
|
"verify_license",
|
||||||
"verify_master_response",
|
"verify_master_response",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,21 +45,8 @@ def _b64d(value: str, *, field: str) -> bytes:
|
|||||||
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
||||||
|
|
||||||
|
|
||||||
def generate_keypair() -> tuple[str, str]:
|
|
||||||
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
|
|
||||||
private = ed25519.Ed25519PrivateKey.generate()
|
|
||||||
return (
|
|
||||||
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
|
|
||||||
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def public_from_private(private_key_b64: str) -> str:
|
|
||||||
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
|
|
||||||
key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
|
||||||
_b64d(private_key_b64, field="private_key")
|
|
||||||
)
|
|
||||||
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
|
def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Iterable, Mapping, Sequence
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .canonical import SIG_CONTEXT_LICENSE, generate_keypair, sign, verify
|
from .canonical import SIG_CONTEXT_LICENSE, sign, verify
|
||||||
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
|
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
|
||||||
|
|
||||||
FORMAT_VERSION = 2
|
FORMAT_VERSION = 2
|
||||||
@@ -147,110 +147,10 @@ class LicenseStatus:
|
|||||||
return self.modules_active and name in self.modules
|
return self.modules_active and name in self.modules
|
||||||
|
|
||||||
|
|
||||||
def _validate_issue_args(
|
|
||||||
license_type: str, role: str, modules: Sequence[str], lifetime: bool
|
|
||||||
) -> None:
|
|
||||||
if license_type not in LICENSE_TYPES:
|
|
||||||
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}")
|
|
||||||
if role not in LICENSE_ROLES:
|
|
||||||
raise ValueError(f"Unbekannte Rolle: {role!r}")
|
|
||||||
unknown = sorted(set(modules) - set(ALL_MODULES))
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"Unbekannte Module: {', '.join(unknown)}")
|
|
||||||
if lifetime and license_type == "trial":
|
|
||||||
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
|
|
||||||
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
|
|
||||||
# Lizenz ohne Module -- ein sinnloser Zustand.
|
|
||||||
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
|
|
||||||
if role != "customer" and modules:
|
|
||||||
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
|
|
||||||
|
|
||||||
|
|
||||||
def issue_license(
|
|
||||||
*,
|
|
||||||
customer: Mapping[str, Any],
|
|
||||||
license_type: str,
|
|
||||||
modules: Iterable[str],
|
|
||||||
valid_days: int,
|
|
||||||
master_private_key_b64: str,
|
|
||||||
master_public_key_b64: str,
|
|
||||||
master_endpoint: str,
|
|
||||||
vendor: Mapping[str, Any],
|
|
||||||
license_id: str | None = None,
|
|
||||||
role: str = "customer",
|
|
||||||
lifetime: bool = False,
|
|
||||||
seq: int = 1,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> tuple[dict[str, Any], str]:
|
|
||||||
"""Stellt eine Lizenz aus.
|
|
||||||
|
|
||||||
Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die
|
|
||||||
an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank,
|
|
||||||
damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann.
|
|
||||||
"""
|
|
||||||
module_list = sorted({str(m) for m in modules})
|
|
||||||
_validate_issue_args(license_type, role, module_list, lifetime)
|
|
||||||
if not lifetime and int(valid_days) <= 0:
|
|
||||||
raise ValueError("valid_days muss positiv sein")
|
|
||||||
|
|
||||||
moment = now or utcnow()
|
|
||||||
client_private, client_public = generate_keypair()
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"fmt": FORMAT_VERSION,
|
|
||||||
"license_id": license_id or str(uuid.uuid4()),
|
|
||||||
"seq": int(seq),
|
|
||||||
"customer": dict(customer),
|
|
||||||
"type": license_type,
|
|
||||||
"role": role,
|
|
||||||
"modules": module_list,
|
|
||||||
"lifetime": bool(lifetime),
|
|
||||||
"issued_at": iso(moment),
|
|
||||||
"not_before": iso(moment),
|
|
||||||
"expires_at": LIFETIME_EXPIRES_AT
|
|
||||||
if lifetime
|
|
||||||
else iso(moment + timedelta(days=int(valid_days))),
|
|
||||||
"license_pubkey": client_public,
|
|
||||||
"master_pubkey": master_public_key_b64,
|
|
||||||
"master_key_id": key_id(master_public_key_b64),
|
|
||||||
"master_endpoint": (master_endpoint or "").rstrip("/"),
|
|
||||||
"vendor": dict(vendor),
|
|
||||||
}
|
|
||||||
signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
|
|
||||||
document = {**payload, "signature": signature}
|
|
||||||
bundle = {
|
|
||||||
"kind": "tesm-license-bundle",
|
|
||||||
"fmt": FORMAT_VERSION,
|
|
||||||
"license": document,
|
|
||||||
"client_key": client_private,
|
|
||||||
}
|
|
||||||
return bundle, client_public
|
|
||||||
|
|
||||||
|
|
||||||
def resign_license(
|
|
||||||
document: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
master_private_key_b64: str,
|
|
||||||
changes: Mapping[str, Any],
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments.
|
|
||||||
|
|
||||||
Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich
|
|
||||||
erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend,
|
|
||||||
damit der Client eine aeltere Revision nicht als Update akzeptiert.
|
|
||||||
"""
|
|
||||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
|
||||||
payload.update(changes)
|
|
||||||
payload["seq"] = int(document.get("seq") or 1) + 1
|
|
||||||
payload["issued_at"] = iso(now or utcnow())
|
|
||||||
_validate_issue_args(
|
|
||||||
str(payload.get("type")),
|
|
||||||
str(payload.get("role")),
|
|
||||||
[str(m) for m in payload.get("modules") or ()],
|
|
||||||
bool(payload.get("lifetime")),
|
|
||||||
)
|
|
||||||
payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
|
def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def build_client_request(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
def check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||||
try:
|
try:
|
||||||
stamp = parse_iso(timestamp)
|
stamp = parse_iso(timestamp)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
@@ -106,89 +106,8 @@ def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
|||||||
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
|
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
|
||||||
|
|
||||||
|
|
||||||
def verify_client_request(
|
|
||||||
request: Mapping[str, Any],
|
|
||||||
license_public_key: str,
|
|
||||||
*,
|
|
||||||
expected_action: str | None = None,
|
|
||||||
now: datetime | None = None,
|
|
||||||
seen_nonce: Callable[[str], bool] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
|
|
||||||
|
|
||||||
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
|
|
||||||
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
|
|
||||||
(in dieser Codebasis: Tabelle ``license_nonces``).
|
|
||||||
"""
|
|
||||||
if not isinstance(request, Mapping):
|
|
||||||
raise ProtocolViolation("Anfrage ist kein Objekt")
|
|
||||||
if int(request.get("fmt") or 0) != FORMAT_VERSION:
|
|
||||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
|
||||||
|
|
||||||
action = str(request.get("action") or "")
|
|
||||||
if action not in ACTIONS:
|
|
||||||
raise ProtocolViolation("Unbekannte Aktion")
|
|
||||||
if expected_action and action != expected_action:
|
|
||||||
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
|
|
||||||
|
|
||||||
nonce = str(request.get("nonce") or "")
|
|
||||||
if not _NONCE_RE.match(nonce):
|
|
||||||
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
|
|
||||||
fingerprint = str(request.get("fingerprint") or "")
|
|
||||||
if not _FINGERPRINT_RE.match(fingerprint):
|
|
||||||
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
|
|
||||||
|
|
||||||
payload = {k: v for k, v in request.items() if k != "signature"}
|
|
||||||
if not verify(
|
|
||||||
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
|
|
||||||
):
|
|
||||||
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
|
|
||||||
|
|
||||||
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
|
|
||||||
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
|
|
||||||
moment = now or utcnow()
|
|
||||||
_check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
|
|
||||||
if seen_nonce is not None and seen_nonce(nonce):
|
|
||||||
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
|
|
||||||
|
|
||||||
report = request.get("report")
|
|
||||||
if report is not None:
|
|
||||||
if not isinstance(report, Mapping):
|
|
||||||
raise ProtocolViolation("Bericht ist kein Objekt")
|
|
||||||
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
|
|
||||||
if total > MAX_REPORT_ROWS:
|
|
||||||
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
|
|
||||||
|
|
||||||
|
|
||||||
def build_master_response(
|
|
||||||
action: str,
|
|
||||||
*,
|
|
||||||
license_id: str,
|
|
||||||
fingerprint: str,
|
|
||||||
request_nonce: str,
|
|
||||||
master_private_key: str,
|
|
||||||
status: str = "ok",
|
|
||||||
detail: str = "",
|
|
||||||
license_update: Mapping[str, Any] | None = None,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
|
|
||||||
if status not in RESPONSE_STATES:
|
|
||||||
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"fmt": FORMAT_VERSION,
|
|
||||||
"license_id": license_id,
|
|
||||||
"fingerprint": fingerprint,
|
|
||||||
"action": action,
|
|
||||||
"status": status,
|
|
||||||
"detail": detail,
|
|
||||||
"nonce": request_nonce,
|
|
||||||
"timestamp": iso(now or utcnow()),
|
|
||||||
}
|
|
||||||
if license_update is not None:
|
|
||||||
payload["license_update"] = dict(license_update)
|
|
||||||
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def verify_master_response(
|
def verify_master_response(
|
||||||
@@ -230,7 +149,7 @@ def verify_master_response(
|
|||||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||||
raise ProtocolViolation("Unbekannter Antwortstatus")
|
raise ProtocolViolation("Unbekannter Antwortstatus")
|
||||||
|
|
||||||
_check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
|
check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
|
||||||
|
|
||||||
update = response.get("license_update")
|
update = response.get("license_update")
|
||||||
if update is not None:
|
if update is not None:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,211 +0,0 @@
|
|||||||
"""Gemeinsame Testfixtures.
|
|
||||||
|
|
||||||
Jeder Test bekommt eine frische Instanz in einem temporaeren Verzeichnis --
|
|
||||||
eigene Datenbank, eigene Schluessel, eigenes Protokollverzeichnis. Nichts
|
|
||||||
beruehrt eine echte Installation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def instance_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
||||||
base = tmp_path / "instance"
|
|
||||||
monkeypatch.setenv("TESM_BASE_DIR", str(base))
|
|
||||||
monkeypatch.setenv("TESM_LICENSE_BASE_DIR", str(tmp_path / "instance-license"))
|
|
||||||
# Tests laufen ueber http:// -- ein Secure-Cookie wuerde nie gesetzt.
|
|
||||||
monkeypatch.setenv("TESM_COOKIE_SECURE", "0")
|
|
||||||
monkeypatch.setenv("TESM_LICENSE_COOKIE_SECURE", "0")
|
|
||||||
monkeypatch.setenv("TESM_DEBUG", "0")
|
|
||||||
monkeypatch.setenv("TESM_LICENSE_DEBUG", "0")
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def core_app(instance_env: Path) -> Iterator[Any]:
|
|
||||||
"""Minimale App auf Basis von ``tesm_core`` -- ohne Fachlogik der beiden Apps."""
|
|
||||||
from flask import render_template_string
|
|
||||||
|
|
||||||
from tesm_core import load_core_config
|
|
||||||
from tesm_core.app_factory import create_base_app
|
|
||||||
from tesm_core.auth.models import login_required
|
|
||||||
from tesm_core.nav import NavItem, NavTree
|
|
||||||
from tesm_core.rbac.common import administration_area, observability_area
|
|
||||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
|
||||||
|
|
||||||
demo = Area(
|
|
||||||
key="demo",
|
|
||||||
label="Demo",
|
|
||||||
icon="grid",
|
|
||||||
resources=(
|
|
||||||
Resource(
|
|
||||||
key="widgets",
|
|
||||||
label="Widgets",
|
|
||||||
actions=(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.SECRETS),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
tree = PermissionTree([demo, administration_area(), observability_area()])
|
|
||||||
nav = NavTree(
|
|
||||||
[
|
|
||||||
NavItem(key="index", label="Start", icon="grid", endpoint="index"),
|
|
||||||
NavItem(
|
|
||||||
key="widgets", label="Widgets", icon="cpu", endpoint="widgets",
|
|
||||||
permissions=("widgets.view",),
|
|
||||||
),
|
|
||||||
NavItem(
|
|
||||||
key="administration",
|
|
||||||
label="Verwaltung",
|
|
||||||
icon="sliders",
|
|
||||||
children=(
|
|
||||||
NavItem(
|
|
||||||
key="users", label="Benutzer", icon="users", endpoint="admin.users",
|
|
||||||
permissions=("users.view",),
|
|
||||||
),
|
|
||||||
NavItem(
|
|
||||||
key="groups", label="Gruppen", icon="shield-users", endpoint="admin.groups",
|
|
||||||
permissions=("groups.view",),
|
|
||||||
),
|
|
||||||
NavItem(
|
|
||||||
key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview",
|
|
||||||
permissions=("trash.view",),
|
|
||||||
),
|
|
||||||
NavItem(
|
|
||||||
key="diagnostics", label="Diagnose", icon="info",
|
|
||||||
endpoint="diagnostics.overview", permissions=("diagnostics.view",),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
presets = (
|
|
||||||
RolePreset(
|
|
||||||
key="viewer",
|
|
||||||
name="Benutzer",
|
|
||||||
description="Nur lesen",
|
|
||||||
grants=(("widgets", (Action.VIEW,)),),
|
|
||||||
is_default=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
config = load_core_config(
|
|
||||||
app_key="tesm", app_name="TESM Test", env_prefix="TESM_", default_base=instance_env
|
|
||||||
)
|
|
||||||
app = create_base_app(
|
|
||||||
config=config,
|
|
||||||
permissions=tree,
|
|
||||||
nav=nav,
|
|
||||||
role_presets=presets,
|
|
||||||
app_short="TT",
|
|
||||||
app_edition="Test",
|
|
||||||
enable_license=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def index(): # type: ignore[no-untyped-def]
|
|
||||||
return render_template_string(
|
|
||||||
'{% extends "tesm_core/base.html" %}{% block page_title %}Start{% endblock %}'
|
|
||||||
"{% block content %}<p>Start</p>{% endblock %}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/widgets")
|
|
||||||
@login_required
|
|
||||||
def widgets(): # type: ignore[no-untyped-def]
|
|
||||||
return render_template_string(
|
|
||||||
'{% extends "tesm_core/base.html" %}{% block page_title %}Widgets{% endblock %}'
|
|
||||||
"{% block content %}<p>Widgets</p>{% endblock %}"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.config.update(TESTING=True)
|
|
||||||
yield app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def client(core_app: Any) -> Any:
|
|
||||||
return core_app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def make_user(core_app: Any):
|
|
||||||
"""Erzeugt ein Konto direkt in der Datenbank."""
|
|
||||||
|
|
||||||
def factory(
|
|
||||||
username: str = "tester",
|
|
||||||
password: str = "Sicher-Passwort-2026",
|
|
||||||
*,
|
|
||||||
is_admin: bool = False,
|
|
||||||
permissions: tuple[str, ...] = (),
|
|
||||||
is_locked: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
from tesm_core.db import Database
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.security import passwords
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
|
|
||||||
"created_at, updated_at) VALUES (?,?,?,?,'local',datetime('now'),datetime('now'))",
|
|
||||||
(username, passwords.hash_password(password), int(is_admin), int(is_locked)),
|
|
||||||
)
|
|
||||||
user_id = int(cursor.lastrowid or 0)
|
|
||||||
if permissions:
|
|
||||||
group = conn.execute(
|
|
||||||
"INSERT INTO groups (name, description, created_at, updated_at) "
|
|
||||||
"VALUES (?,'Testgruppe',datetime('now'),datetime('now'))",
|
|
||||||
(f"grp-{username}",),
|
|
||||||
)
|
|
||||||
group_id = int(group.lastrowid or 0)
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
|
||||||
[(group_id, p) for p in extension.permissions.sanitize(permissions)],
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id)
|
|
||||||
)
|
|
||||||
row = Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
|
|
||||||
assert row is not None
|
|
||||||
return {**row, "password": password}
|
|
||||||
|
|
||||||
return factory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def login(client: Any):
|
|
||||||
def do_login(username: str, password: str) -> Any:
|
|
||||||
return client.post(
|
|
||||||
"/login",
|
|
||||||
data={"username": username, "password": password, "csrf_token": _token(client)},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return do_login
|
|
||||||
|
|
||||||
|
|
||||||
def _token(client: Any) -> str:
|
|
||||||
"""Holt ein CSRF-Token aus der Anmeldeseite."""
|
|
||||||
page = client.get("/login")
|
|
||||||
body = page.get_data(as_text=True)
|
|
||||||
marker = 'name="csrf_token" value="'
|
|
||||||
start = body.index(marker) + len(marker)
|
|
||||||
return body[start : body.index('"', start)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def csrf_token(client: Any):
|
|
||||||
def get(path: str = "/login") -> str:
|
|
||||||
page = client.get(path)
|
|
||||||
body = page.get_data(as_text=True)
|
|
||||||
marker = 'name="csrf_token" value="'
|
|
||||||
if marker not in body:
|
|
||||||
marker = 'data-csrf="'
|
|
||||||
start = body.index(marker) + len(marker)
|
|
||||||
return body[start : body.index('"', start)]
|
|
||||||
|
|
||||||
return get
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
"""End-to-End-Rauchtest gegen eine laufende Installation.
|
|
||||||
|
|
||||||
Laeuft auf dem Zielhost, spricht ueber HTTP mit nginx und der echten Anwendung
|
|
||||||
-- kein Testclient, keine Attrappen. Prueft genau das, was ein Mensch beim
|
|
||||||
ersten Durchklicken pruefen wuerde, plus die Dinge, die man beim Klicken leicht
|
|
||||||
uebersieht (Sicherheitsheader, CSRF, statische Dateien, Rechteschranken).
|
|
||||||
|
|
||||||
Aufruf:
|
|
||||||
python3 e2e_smoke.py http://127.0.0.1:8080 admin 'Passwort'
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.cookiejar
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import ssl
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"')
|
|
||||||
BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"')
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, base: str) -> None:
|
|
||||||
self.base = base.rstrip("/")
|
|
||||||
self.https = self.base.startswith("https://")
|
|
||||||
self.jar = http.cookiejar.CookieJar()
|
|
||||||
|
|
||||||
# Der Test laeuft auf dem Zielhost selbst gegen ein Zertifikat, das
|
|
||||||
# dieselbe Maschine ausgestellt hat. Es zu pruefen hiesse, das eigene
|
|
||||||
# Zertifikat gegen sich selbst zu pruefen -- geprueft wird hier, ob TLS
|
|
||||||
# steht und die Anwendung darueber antwortet.
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
self.tls = urllib.request.HTTPSHandler(context=context)
|
|
||||||
|
|
||||||
self.opener = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.jar),
|
|
||||||
urllib.request.HTTPRedirectHandler(),
|
|
||||||
self.tls,
|
|
||||||
)
|
|
||||||
self.opener.addheaders = [("User-Agent", "tesm-e2e/2.0")]
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, *_args, **_kwargs): # type: ignore[override]
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Zweiter Opener ohne Weiterleitung: nur so laesst sich pruefen, ob eine
|
|
||||||
# Seite tatsaechlich mit 302 auf die Anmeldung verweist.
|
|
||||||
self.plain = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect(), self.tls
|
|
||||||
)
|
|
||||||
self.plain.addheaders = [("User-Agent", "tesm-e2e/2.0")]
|
|
||||||
|
|
||||||
def get(self, path: str) -> tuple[int, str, dict[str, str]]:
|
|
||||||
request = urllib.request.Request(self.base + path)
|
|
||||||
try:
|
|
||||||
with self.opener.open(request, timeout=20) as response:
|
|
||||||
return response.status, response.read().decode("utf-8", "replace"), dict(
|
|
||||||
response.headers
|
|
||||||
)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers)
|
|
||||||
|
|
||||||
def get_raw(self, path: str) -> int:
|
|
||||||
"""Statuscode ohne Weiterleitung zu folgen."""
|
|
||||||
request = urllib.request.Request(self.base + path)
|
|
||||||
try:
|
|
||||||
with self.plain.open(request, timeout=20) as response:
|
|
||||||
return response.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code
|
|
||||||
|
|
||||||
def post(self, path: str, data: dict[str, object]) -> tuple[int, str]:
|
|
||||||
encoded = urllib.parse.urlencode(
|
|
||||||
[
|
|
||||||
(key, str(item))
|
|
||||||
for key, value in data.items()
|
|
||||||
for item in (value if isinstance(value, (list, tuple)) else [value])
|
|
||||||
]
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
self.base + path,
|
|
||||||
data=encoded,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Origin": self.base,
|
|
||||||
"Referer": self.base + path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with self.opener.open(request, timeout=30) as response:
|
|
||||||
return response.status, response.read().decode("utf-8", "replace")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
def token(self, path: str) -> str:
|
|
||||||
_, body, _ = self.get(path)
|
|
||||||
match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body)
|
|
||||||
if not match:
|
|
||||||
raise AssertionError(f"Kein CSRF-Token auf {path}")
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
PASSED: list[str] = []
|
|
||||||
FAILED: list[str] = []
|
|
||||||
|
|
||||||
#: Jeder Lauf legt eigene Datensaetze an. Sonst laesst sich der Test kein
|
|
||||||
#: zweites Mal gegen dieselbe Installation ausfuehren -- beim ersten Lauf
|
|
||||||
#: landet ein Geraet im Papierkorb, und die Anwendung weigert sich danach
|
|
||||||
#: voellig zu Recht, dieselbe MAC noch einmal zu vergeben.
|
|
||||||
RUN = secrets.token_hex(3)
|
|
||||||
MAC = ":".join(("AA", "BB", RUN[0:2], RUN[2:4], RUN[4:6], "01")).upper()
|
|
||||||
SUBNET = f"10.99.{int(RUN[0:2], 16)}"
|
|
||||||
|
|
||||||
FLASH_RE = re.compile(r'id="flashed-messages">(.*?)</script>', re.S)
|
|
||||||
ROW_SPLIT = re.compile(r"<tr[ >]|<li[ >]")
|
|
||||||
|
|
||||||
|
|
||||||
def flash(body: str) -> str:
|
|
||||||
"""Die Meldungen der Seite -- damit ein Fehlschlag erklaert, was schiefging."""
|
|
||||||
match = FLASH_RE.search(body)
|
|
||||||
if not match:
|
|
||||||
return "keine Meldung"
|
|
||||||
try:
|
|
||||||
return " | ".join(text for _level, text in json.loads(match.group(1)))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return match.group(1)[:120]
|
|
||||||
|
|
||||||
|
|
||||||
def id_for(body: str, needle: str, pattern: str) -> str:
|
|
||||||
"""Sucht die Kennung in der Zeile, in der ``needle`` steht.
|
|
||||||
|
|
||||||
Die Listenansicht nennt die Kennung nur in Formular- und Link-Zielen. Der
|
|
||||||
Test darf sie nicht raten -- eine feste ``1`` funktioniert genau einmal und
|
|
||||||
trifft beim naechsten Lauf einen fremden Datensatz.
|
|
||||||
"""
|
|
||||||
for chunk in ROW_SPLIT.split(body):
|
|
||||||
if needle in chunk:
|
|
||||||
match = re.search(pattern, chunk)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def check(name: str, condition: bool, detail: str = "") -> None:
|
|
||||||
if condition:
|
|
||||||
PASSED.append(name)
|
|
||||||
print(f" [ok] {name}")
|
|
||||||
else:
|
|
||||||
FAILED.append(f"{name}: {detail}")
|
|
||||||
print(f" [FEHL] {name} -- {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def local_host_fingerprints() -> set[str]:
|
|
||||||
"""Die Fingerprints, die ``ssh-keyscan`` fuer diesen Host meldet.
|
|
||||||
|
|
||||||
Der Vergleich ist der Kern der Pruefung: die Anwendung darf nicht
|
|
||||||
irgendeinen plausiblen Wert anzeigen, sondern genau den des Gegenuebers.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
scan = subprocess.run(
|
|
||||||
["ssh-keyscan", "-T", "5", "-p", "22", "127.0.0.1"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
fingerprints: set[str] = set()
|
|
||||||
for line in scan.stdout.splitlines():
|
|
||||||
if not line.strip() or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
result = subprocess.run(
|
|
||||||
["ssh-keygen", "-lf", "-"], input=line, capture_output=True, text=True, timeout=10
|
|
||||||
)
|
|
||||||
match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", result.stdout)
|
|
||||||
if match:
|
|
||||||
fingerprints.add(match.group(1))
|
|
||||||
return fingerprints
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def main(base: str, username: str, password: str) -> int:
|
|
||||||
client = Client(base)
|
|
||||||
print(f"\n== Rauchtest gegen {base} (Lauf {RUN}) ==\n")
|
|
||||||
|
|
||||||
# -- oeffentlich ---------------------------------------------------------
|
|
||||||
print("Oeffentlich erreichbar:")
|
|
||||||
status, body, headers = client.get("/gesundheit")
|
|
||||||
check("Gesundheitsendpunkt", status == 200 and json.loads(body)["status"] == "ok", body[:80])
|
|
||||||
|
|
||||||
status, body, headers = client.get("/login")
|
|
||||||
check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}")
|
|
||||||
if client.https:
|
|
||||||
cookie = headers.get("Set-Cookie", "")
|
|
||||||
check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie")
|
|
||||||
check("Sitzungscookie HttpOnly", "HttpOnly" in cookie, cookie[:80] or "kein Cookie")
|
|
||||||
check("Sitzungscookie SameSite", "SameSite" in cookie, cookie[:80] or "kein Cookie")
|
|
||||||
csp = headers.get("Content-Security-Policy", "")
|
|
||||||
check("CSP mit Nonce", "nonce-" in csp and "unsafe-inline" not in csp.split("style-src")[0],
|
|
||||||
csp[:90])
|
|
||||||
check("X-Frame-Options", headers.get("X-Frame-Options") == "DENY", str(headers.get("X-Frame-Options")))
|
|
||||||
check("Vorgangsnummer im Header", bool(headers.get("X-Request-Id")), "fehlt")
|
|
||||||
|
|
||||||
status, body, _ = client.get("/")
|
|
||||||
check("Oeffentliche Statusuebersicht", status == 200 and "Statusuebersicht" in body,
|
|
||||||
f"HTTP {status}")
|
|
||||||
check("Ohne Anmeldung keine Details", "oeffentliche Ansicht" in body, "Hinweis fehlt")
|
|
||||||
|
|
||||||
for asset in ("/core-assets/css/base.css", "/core-assets/js/app.js"):
|
|
||||||
status, body, _ = client.get(asset)
|
|
||||||
check(f"Statische Datei {asset}", status == 200 and len(body) > 1000, f"HTTP {status}")
|
|
||||||
|
|
||||||
print("\nSchranken ohne Anmeldung:")
|
|
||||||
for path in ("/clients/", "/verwaltung/benutzer", "/diagnose/"):
|
|
||||||
status = client.get_raw(path)
|
|
||||||
check(f"{path} verlangt Anmeldung", status in (302, 401), f"HTTP {status}")
|
|
||||||
|
|
||||||
print("\nCSRF:")
|
|
||||||
status, body = client.post("/login", {"username": "x", "password": "y"})
|
|
||||||
check("POST ohne Token wird abgewiesen", status == 403, f"HTTP {status}")
|
|
||||||
|
|
||||||
# -- Anmeldung -----------------------------------------------------------
|
|
||||||
print("\nAnmeldung:")
|
|
||||||
token = client.token("/login")
|
|
||||||
status, body = client.post(
|
|
||||||
"/login", {"username": username, "password": password, "csrf_token": token}
|
|
||||||
)
|
|
||||||
check("Anmeldung erfolgreich", "Willkommen" in body or status == 200, f"HTTP {status}")
|
|
||||||
|
|
||||||
status, body, _ = client.get("/konto/")
|
|
||||||
check("Kontoseite erreichbar", status == 200 and username in body, f"HTTP {status}")
|
|
||||||
|
|
||||||
# -- Seiten --------------------------------------------------------------
|
|
||||||
print("\nSeiten (angemeldet):")
|
|
||||||
pages = [
|
|
||||||
("/", "Statusuebersicht"),
|
|
||||||
("/clients/", "Clients"),
|
|
||||||
("/switche/", "Switche"),
|
|
||||||
("/zugangsdaten/", "Zugangsdaten"),
|
|
||||||
("/protokolle/laufend", "Protokoll"),
|
|
||||||
("/protokolle/aenderungen", "Aenderungsprotokoll"),
|
|
||||||
("/protokolle/neustarts", "Neustarts"),
|
|
||||||
("/einstellungen/system", "Systemeinstellungen"),
|
|
||||||
("/einstellungen/netzwerk", "Netzwerk"),
|
|
||||||
("/einstellungen/webserver", "Webserver"),
|
|
||||||
("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"),
|
|
||||||
("/lizenz/", "Lizenz"),
|
|
||||||
("/sicherung/", "Sicherung"),
|
|
||||||
("/papierkorb/", "Papierkorb"),
|
|
||||||
("/diagnose/", "Diagnose"),
|
|
||||||
("/verwaltung/benutzer", "Benutzer"),
|
|
||||||
("/verwaltung/gruppen", "Gruppen"),
|
|
||||||
("/verwaltung/sitzungen", "Sitzungen"),
|
|
||||||
("/konto/sicherheit", "Sicherheit"),
|
|
||||||
]
|
|
||||||
for path, needle in pages:
|
|
||||||
status, body, _ = client.get(path)
|
|
||||||
ok = status == 200 and needle in body and "Interner Fehler" not in body
|
|
||||||
check(f"{path}", ok, f"HTTP {status}")
|
|
||||||
|
|
||||||
# Die Modulrouten sind an die Lizenz gebunden. Welcher Zustand gerade gilt,
|
|
||||||
# darf der Test nicht vorschreiben -- er darf nur verlangen, dass alle drei
|
|
||||||
# dasselbe sagen. Ein Modul, das ohne Lizenz durchkommt, waere ein Loch;
|
|
||||||
# ein Modul, das mit Lizenz 404 liefert, waere ein Ausfall.
|
|
||||||
print("\nLizenzpflichtige Module:")
|
|
||||||
module_status = {
|
|
||||||
path: client.get(path)[0] for path in ("/dhcp/", "/wartung/", "/dateifreigaben/")
|
|
||||||
}
|
|
||||||
licensed = set(module_status.values()) == {200}
|
|
||||||
unlicensed = set(module_status.values()) == {404}
|
|
||||||
check(
|
|
||||||
"Alle Modulrouten im selben Zustand",
|
|
||||||
licensed or unlicensed,
|
|
||||||
", ".join(f"{path} {code}" for path, code in module_status.items()),
|
|
||||||
)
|
|
||||||
print(f" [info] Lizenz ist {'aktiv' if licensed else 'nicht aktiv'}.")
|
|
||||||
|
|
||||||
# -- Daten anlegen -------------------------------------------------------
|
|
||||||
print("\nDaten anlegen:")
|
|
||||||
credential_name = f"E2E-Switchlogin-{RUN}"
|
|
||||||
switch_name = f"e2e-switch-{RUN}"
|
|
||||||
device_name = f"E2E-Kamera-{RUN}"
|
|
||||||
secret = f"Geheim-fuer-den-Test-{RUN}"
|
|
||||||
|
|
||||||
token = client.token("/zugangsdaten/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/zugangsdaten/neu",
|
|
||||||
{
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": credential_name,
|
|
||||||
"username": "operator",
|
|
||||||
"secret": secret,
|
|
||||||
"category": "switch",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
check("Zugangsdaten angelegt", "angelegt" in body, flash(body))
|
|
||||||
credential_id = id_for(body, credential_name, r"/zugangsdaten/(\d+)/")
|
|
||||||
check("Kennung der Zugangsdaten gefunden", bool(credential_id), "nicht gefunden")
|
|
||||||
|
|
||||||
token = client.token("/switche/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/switche/neu",
|
|
||||||
{
|
|
||||||
"csrf_token": token,
|
|
||||||
"hostname": switch_name,
|
|
||||||
"ip": f"{SUBNET}.2",
|
|
||||||
"ssh_port": "22",
|
|
||||||
"credential_id": credential_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
check("Switch angelegt", "angelegt" in body, flash(body))
|
|
||||||
switch_id = id_for(body, switch_name, r"/switche/(\d+)")
|
|
||||||
check("Kennung des Switches gefunden", bool(switch_id), "nicht gefunden")
|
|
||||||
|
|
||||||
token = client.token("/clients/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/clients/neu",
|
|
||||||
{
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": device_name,
|
|
||||||
"mac": MAC,
|
|
||||||
"ip": f"{SUBNET}.50",
|
|
||||||
"switch_id": switch_id,
|
|
||||||
"port": "7",
|
|
||||||
"ssh_port": "22",
|
|
||||||
"is_active": "1",
|
|
||||||
"auto_restart": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
check("Geraet angelegt", "angelegt" in body, flash(body))
|
|
||||||
device_id = id_for(body, device_name, r"/clients/(\d+)")
|
|
||||||
check("Kennung des Geraets gefunden", bool(device_id), "nicht gefunden")
|
|
||||||
|
|
||||||
status, body, _ = client.get("/clients/")
|
|
||||||
check(
|
|
||||||
"Geraet in der Liste",
|
|
||||||
device_name in body and MAC.lower() in body,
|
|
||||||
"nicht gefunden",
|
|
||||||
)
|
|
||||||
|
|
||||||
status, body, _ = client.get("/")
|
|
||||||
check("Geraet auf der Uebersicht", device_name in body, "nicht gefunden")
|
|
||||||
|
|
||||||
print("\nValidierung:")
|
|
||||||
token = client.token("/clients/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/clients/neu",
|
|
||||||
{"csrf_token": token, "name": "Kaputt", "mac": "unsinn", "ip": f"{SUBNET}.51"},
|
|
||||||
)
|
|
||||||
check("Ungueltige MAC abgelehnt", "hexadezimale" in body, flash(body))
|
|
||||||
|
|
||||||
token = client.token("/clients/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/clients/neu",
|
|
||||||
{"csrf_token": token, "name": "Kaputt", "mac": MAC[:-2] + "FE", "ip": "999.1.1.1"},
|
|
||||||
)
|
|
||||||
check("Ungueltige IP abgelehnt", "gueltige IP-Adresse" in body, flash(body))
|
|
||||||
|
|
||||||
token = client.token("/clients/")
|
|
||||||
status, body = client.post(
|
|
||||||
"/clients/neu",
|
|
||||||
{"csrf_token": token, "name": "Doppelt", "mac": MAC, "ip": f"{SUBNET}.52"},
|
|
||||||
)
|
|
||||||
check("Doppelte MAC abgelehnt", "angelegt" not in body, "zweimal dieselbe MAC")
|
|
||||||
|
|
||||||
# -- Host-Schluessel eines Clients ---------------------------------------
|
|
||||||
# Gegen einen echten SSH-Dienst: der Host selbst. Nur so ist geprueft, dass
|
|
||||||
# der gelesene Fingerprint tatsaechlich der des Gegenuebers ist -- eine
|
|
||||||
# Attrappe wuerde genau den Fehler durchlassen, auf den es hier ankommt.
|
|
||||||
print("\nSSH-Host-Schluessel eines Clients:")
|
|
||||||
token = client.token("/clients/")
|
|
||||||
ssh_name = f"E2E-SSH-{RUN}"
|
|
||||||
status, body = client.post(
|
|
||||||
"/clients/neu",
|
|
||||||
{
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": ssh_name,
|
|
||||||
"mac": MAC[:-2] + "0F",
|
|
||||||
"ip": "127.0.0.1",
|
|
||||||
"ssh_port": "22",
|
|
||||||
"credential_id": credential_id,
|
|
||||||
"is_active": "0",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
check("Client fuer den SSH-Test angelegt", "angelegt" in body, flash(body))
|
|
||||||
ssh_id = id_for(body, ssh_name, r"/clients/(\d+)")
|
|
||||||
check("Kennung gefunden", bool(ssh_id), "nicht gefunden")
|
|
||||||
|
|
||||||
if ssh_id:
|
|
||||||
status, body, _ = client.get(f"/clients/{ssh_id}")
|
|
||||||
check("Detailseite zeigt die Schluesselkarte", "SSH-Host-Schluessel" in body, "fehlt")
|
|
||||||
check("Zustand offen", "Noch kein Schluessel hinterlegt" in body, "unerwartet")
|
|
||||||
|
|
||||||
token = client.token(f"/clients/{ssh_id}")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/clients/{ssh_id}/hostkey/pruefen", {"csrf_token": token}
|
|
||||||
)
|
|
||||||
check("Schluessel ausgelesen", "Host-Schluessel gelesen" in body, flash(body))
|
|
||||||
|
|
||||||
match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", body)
|
|
||||||
fingerprint = match.group(1) if match else ""
|
|
||||||
check("Fingerprint erhalten", bool(fingerprint), "nicht gefunden")
|
|
||||||
|
|
||||||
expected = local_host_fingerprints()
|
|
||||||
check(
|
|
||||||
"Fingerprint stimmt mit ssh-keyscan ueberein",
|
|
||||||
bool(expected) and fingerprint in expected,
|
|
||||||
f"gelesen {fingerprint}, erwartet eines von {sorted(expected)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
key_match = re.search(r'name="key_type" value="([^"]+)"', body)
|
|
||||||
key_type = key_match.group(1) if key_match else ""
|
|
||||||
token = client.token(f"/clients/{ssh_id}")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/clients/{ssh_id}/hostkey/freigeben",
|
|
||||||
{"csrf_token": token, "key_type": key_type, "fingerprint": fingerprint},
|
|
||||||
)
|
|
||||||
check("Schluessel freigegeben", "freigegeben" in body, flash(body))
|
|
||||||
check("Fingerprint steht auf der Seite", fingerprint in body, "nicht gefunden")
|
|
||||||
|
|
||||||
token = client.token(f"/clients/{ssh_id}")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/clients/{ssh_id}/hostkey/freigeben",
|
|
||||||
{"csrf_token": token, "key_type": key_type, "fingerprint": "SHA256:zu-kurz"},
|
|
||||||
)
|
|
||||||
check("Unsinniger Fingerprint abgelehnt", "kein gueltiger Fingerprint" in body, flash(body))
|
|
||||||
|
|
||||||
status, body, _ = client.get("/protokolle/aenderungen")
|
|
||||||
check("Freigabe protokolliert", "device.host_key_trusted" in body, "kein Eintrag")
|
|
||||||
|
|
||||||
token = client.token(f"/clients/{ssh_id}")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/clients/{ssh_id}/hostkey/verwerfen", {"csrf_token": token}
|
|
||||||
)
|
|
||||||
check("Schluessel verworfen", "entfernt" in body, flash(body))
|
|
||||||
|
|
||||||
print("\nNeustart-Protokoll:")
|
|
||||||
status, body, _ = client.get("/protokolle/neustarts")
|
|
||||||
check("Seite heisst Neustarts", status == 200 and "Neustarts" in body, f"HTTP {status}")
|
|
||||||
check("Alte PoE-Adresse ist weg", client.get_raw("/protokolle/poe") == 404, "noch da")
|
|
||||||
check("Filter nach Methode vorhanden", "methode=ssh" in body, "kein Filter")
|
|
||||||
|
|
||||||
print("\nGeheimnisse:")
|
|
||||||
token = client.token("/zugangsdaten/")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/zugangsdaten/{credential_id}/anzeigen", {"csrf_token": token}
|
|
||||||
)
|
|
||||||
check("Klartextanzeige moeglich (frisch angemeldet)", secret in body, f"HTTP {status}")
|
|
||||||
status, body, _ = client.get("/protokolle/aenderungen")
|
|
||||||
check("Zugriff protokolliert", "credential.secret_revealed" in body, "kein Eintrag")
|
|
||||||
|
|
||||||
print("\nPapierkorb:")
|
|
||||||
token = client.token("/clients/")
|
|
||||||
client.post(f"/clients/{device_id}/loeschen", {"csrf_token": token})
|
|
||||||
status, body, _ = client.get("/papierkorb/")
|
|
||||||
check("Geloeschtes Geraet im Papierkorb", device_name in body, "nicht gefunden")
|
|
||||||
token = client.token("/papierkorb/")
|
|
||||||
status, body = client.post(
|
|
||||||
f"/papierkorb/devices/{device_id}/wiederherstellen", {"csrf_token": token}
|
|
||||||
)
|
|
||||||
if licensed:
|
|
||||||
check("Wiederherstellen mit Lizenz moeglich", "wiederhergestellt" in body, flash(body))
|
|
||||||
else:
|
|
||||||
check("Wiederherstellen ohne Lizenz gesperrt", "Lizenz" in body, flash(body))
|
|
||||||
|
|
||||||
print("\nDiagnose:")
|
|
||||||
status, body, _ = client.get("/diagnose/")
|
|
||||||
check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?")
|
|
||||||
# Nicht "liegt die Datei da", sondern "antwortet er". Genau der
|
|
||||||
# Unterschied hat einmal verdeckt, dass jede Systemaktion an
|
|
||||||
# NoNewPrivileges scheiterte, waehrend die Seite "erreichbar" meldete.
|
|
||||||
check("Systemhelfer antwortet", "Helfer antwortet" in body, "Helfer meldet sich nicht")
|
|
||||||
check("Schluesselrechte in Ordnung", "in Ordnung" in body, "Problem gemeldet")
|
|
||||||
|
|
||||||
print("\nAbmeldung:")
|
|
||||||
token = client.token("/konto/")
|
|
||||||
status, body = client.post("/logout", {"csrf_token": token})
|
|
||||||
status = client.get_raw("/clients/")
|
|
||||||
check("Nach Abmeldung gesperrt", status in (302, 401), f"HTTP {status}")
|
|
||||||
|
|
||||||
print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==")
|
|
||||||
for entry in FAILED:
|
|
||||||
print(f" FEHLER: {entry}")
|
|
||||||
return 1 if FAILED else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 4:
|
|
||||||
print(__doc__)
|
|
||||||
raise SystemExit(2)
|
|
||||||
raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3]))
|
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
"""Tests der Sicherheitsgrenzen des Kerns.
|
|
||||||
|
|
||||||
Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte
|
|
||||||
oder nur im Template durchgesetzt wurde.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tesm_core import audit
|
|
||||||
from tesm_core.db import Database, NestedConnectionError
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.keystore import DecryptionError, Keystore
|
|
||||||
from tesm_core.migrations import split_statements
|
|
||||||
from tesm_core.rbac import service as rbac
|
|
||||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
|
||||||
from tesm_core.security import passwords, totp
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Rechtebaum
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def _tree() -> PermissionTree:
|
|
||||||
return PermissionTree(
|
|
||||||
[
|
|
||||||
Area(
|
|
||||||
key="ops",
|
|
||||||
label="Betrieb",
|
|
||||||
icon="cpu",
|
|
||||||
resources=(
|
|
||||||
Resource(
|
|
||||||
"devices",
|
|
||||||
"Geraete",
|
|
||||||
(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE),
|
|
||||||
),
|
|
||||||
Resource("switches", "Switche", (Action.VIEW, Action.EDIT)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Area(
|
|
||||||
key="admin",
|
|
||||||
label="Verwaltung",
|
|
||||||
icon="sliders",
|
|
||||||
resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_area_columns_only_contain_used_actions():
|
|
||||||
tree = _tree()
|
|
||||||
ops = tree.area("ops")
|
|
||||||
admin = tree.area("admin")
|
|
||||||
assert Action.EXECUTE in ops.columns()
|
|
||||||
assert Action.EXECUTE not in admin.columns()
|
|
||||||
assert Action.DELETE not in admin.columns()
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_adds_view_and_area_switch():
|
|
||||||
tree = _tree()
|
|
||||||
result = tree.sanitize(["devices.delete"])
|
|
||||||
assert result == ["devices.delete", "devices.view", "ops.view"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_drops_unknown_permissions():
|
|
||||||
tree = _tree()
|
|
||||||
assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_kill_switch_disables_all_children():
|
|
||||||
tree = _tree()
|
|
||||||
granted = {"devices.view", "devices.edit", "switches.view"}
|
|
||||||
assert tree.effective(granted) == set() # ohne ops.view greift nichts
|
|
||||||
assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_resource_keys_rejected():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PermissionTree(
|
|
||||||
[
|
|
||||||
Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
|
||||||
Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resource_without_view_rejected():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))])
|
|
||||||
|
|
||||||
|
|
||||||
def test_preset_expansion():
|
|
||||||
tree = _tree()
|
|
||||||
preset = RolePreset(
|
|
||||||
key="ops-read",
|
|
||||||
name="Betrieb lesen",
|
|
||||||
description="",
|
|
||||||
grants=(("devices", (Action.VIEW,)), ("switches", "*")),
|
|
||||||
)
|
|
||||||
assert tree.expand_preset(preset) == [
|
|
||||||
"devices.view",
|
|
||||||
"ops.view",
|
|
||||||
"switches.edit",
|
|
||||||
"switches.view",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Eskalationsschutz
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
class _Actor:
|
|
||||||
def __init__(self, is_admin: bool, permissions: set[str]):
|
|
||||||
self.is_admin = is_admin
|
|
||||||
self.id = 42
|
|
||||||
self._permissions = permissions
|
|
||||||
|
|
||||||
def has(self, *permissions: str) -> bool:
|
|
||||||
return self.is_admin or all(p in self._permissions for p in permissions)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_amplification_blocks_unknown_permission():
|
|
||||||
tree = _tree()
|
|
||||||
actor = _Actor(False, {"ops.view", "devices.view"})
|
|
||||||
with pytest.raises(rbac.PermissionDenied) as exc:
|
|
||||||
rbac.guard_no_amplification(actor, tree, ["devices.delete"])
|
|
||||||
assert "nicht besitzen" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_amplification_allows_own_permissions():
|
|
||||||
tree = _tree()
|
|
||||||
actor = _Actor(False, {"ops.view", "devices.view"})
|
|
||||||
rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_may_grant_anything():
|
|
||||||
tree = _tree()
|
|
||||||
rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_group_editor_cannot_escalate_via_new_group(core_app, make_user):
|
|
||||||
"""Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen."""
|
|
||||||
from tesm_core.auth.service import load_user
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view"))
|
|
||||||
with core_app.app_context(), extension.database.session() as conn:
|
|
||||||
actor = load_user(conn, int(row["id"]))
|
|
||||||
assert actor is not None
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
with pytest.raises(rbac.PermissionDenied):
|
|
||||||
rbac.create_group(
|
|
||||||
conn,
|
|
||||||
name="Hintertuer",
|
|
||||||
description="",
|
|
||||||
permissions=["users.create", "administration.view"],
|
|
||||||
tree=extension.permissions,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_admin_cannot_be_demoted(core_app, make_user):
|
|
||||||
from tesm_core.auth.service import load_user
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
with core_app.app_context(), extension.database.session() as conn:
|
|
||||||
actor = load_user(conn, int(admin_row["id"]))
|
|
||||||
second = conn.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) "
|
|
||||||
"VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))"
|
|
||||||
)
|
|
||||||
second_id = int(second.lastrowid or 0)
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
# Solange zwei Admins existieren, ist das Herabstufen erlaubt.
|
|
||||||
rbac.update_user(
|
|
||||||
conn, second_id, actor=actor, tree=extension.permissions, is_admin=False
|
|
||||||
)
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
with pytest.raises(rbac.PermissionDenied):
|
|
||||||
rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen")
|
|
||||||
|
|
||||||
|
|
||||||
def test_cannot_change_own_admin_flag(core_app, make_user):
|
|
||||||
from tesm_core.auth.service import load_user
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
with core_app.app_context(), extension.database.session() as conn:
|
|
||||||
actor = load_user(conn, int(row["id"]))
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
with pytest.raises(rbac.PermissionDenied):
|
|
||||||
rbac.update_user(
|
|
||||||
conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_touch_admin_account(core_app, make_user):
|
|
||||||
from tesm_core.auth.service import load_user
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view"))
|
|
||||||
with core_app.app_context(), extension.database.session() as conn:
|
|
||||||
actor = load_user(conn, int(editor_row["id"]))
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
with pytest.raises(rbac.PermissionDenied):
|
|
||||||
rbac.update_user(
|
|
||||||
conn,
|
|
||||||
int(admin_row["id"]),
|
|
||||||
actor=actor,
|
|
||||||
tree=extension.permissions,
|
|
||||||
is_locked=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_group_permissions_locked(core_app, make_user):
|
|
||||||
from tesm_core.auth.service import load_user
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
with core_app.app_context(), extension.database.session() as conn:
|
|
||||||
actor = load_user(conn, int(row["id"]))
|
|
||||||
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
with pytest.raises(rbac.PermissionDenied):
|
|
||||||
rbac.update_group(
|
|
||||||
conn,
|
|
||||||
default.id,
|
|
||||||
permissions=[],
|
|
||||||
tree=extension.permissions,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
# Mit ausdruecklicher Freischaltung geht es.
|
|
||||||
rbac.update_group(
|
|
||||||
conn,
|
|
||||||
default.id,
|
|
||||||
permissions=[],
|
|
||||||
tree=extension.permissions,
|
|
||||||
actor=actor,
|
|
||||||
unlock_system=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_presets_are_not_reset_on_restart(core_app):
|
|
||||||
"""Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten."""
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,))
|
|
||||||
created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets)
|
|
||||||
assert created == []
|
|
||||||
assert rbac.group_permissions(conn, default.id) == []
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Datenbank
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_second_connection_during_write_transaction_raises(tmp_path):
|
|
||||||
database = Database(tmp_path / "test.db")
|
|
||||||
with database.session() as conn:
|
|
||||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
||||||
with database.transaction(conn):
|
|
||||||
conn.execute("INSERT INTO t VALUES (1)")
|
|
||||||
with pytest.raises(NestedConnectionError) as exc:
|
|
||||||
database.connect()
|
|
||||||
assert "Schreibtransaktion" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_transactions_commit_once(tmp_path):
|
|
||||||
database = Database(tmp_path / "test.db")
|
|
||||||
with database.session() as conn:
|
|
||||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
||||||
with database.transaction(conn):
|
|
||||||
conn.execute("INSERT INTO t VALUES (1)")
|
|
||||||
with database.transaction(conn):
|
|
||||||
conn.execute("INSERT INTO t VALUES (2)")
|
|
||||||
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_inner_rollback_keeps_outer(tmp_path):
|
|
||||||
database = Database(tmp_path / "test.db")
|
|
||||||
with database.session() as conn:
|
|
||||||
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
||||||
with database.transaction(conn):
|
|
||||||
conn.execute("INSERT INTO t VALUES (1)")
|
|
||||||
try:
|
|
||||||
with database.transaction(conn):
|
|
||||||
conn.execute("INSERT INTO t VALUES (2)")
|
|
||||||
raise RuntimeError("abbruch")
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_keys_enforced(tmp_path):
|
|
||||||
database = Database(tmp_path / "test.db")
|
|
||||||
with database.session() as conn:
|
|
||||||
conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
|
||||||
conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))")
|
|
||||||
with pytest.raises(sqlite3.IntegrityError):
|
|
||||||
conn.execute("INSERT INTO child VALUES (99)")
|
|
||||||
|
|
||||||
|
|
||||||
def test_wal_mode_active(tmp_path):
|
|
||||||
database = Database(tmp_path / "test.db")
|
|
||||||
with database.session() as conn:
|
|
||||||
assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal"
|
|
||||||
|
|
||||||
|
|
||||||
def test_split_statements_handles_comments_and_strings():
|
|
||||||
script = """
|
|
||||||
-- ein Kommentar mit ; Semikolon
|
|
||||||
CREATE TABLE t (a TEXT DEFAULT 'x;y');
|
|
||||||
INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */
|
|
||||||
"""
|
|
||||||
statements = split_statements(script)
|
|
||||||
assert len(statements) == 2
|
|
||||||
assert "x;y" in statements[0]
|
|
||||||
assert "a;b" in statements[1]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Schluessel und Verschluesselung
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_keystore_roundtrip_and_aad_binding(tmp_path):
|
|
||||||
keystore = Keystore(tmp_path / "data.keys")
|
|
||||||
token = keystore.encrypt("geheim", aad="credentials.password:1")
|
|
||||||
assert keystore.decrypt(token, aad="credentials.password:1") == "geheim"
|
|
||||||
# Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler.
|
|
||||||
with pytest.raises(DecryptionError):
|
|
||||||
keystore.decrypt(token, aad="credentials.password:2")
|
|
||||||
|
|
||||||
|
|
||||||
def test_keystore_rotation_keeps_old_values_readable(tmp_path):
|
|
||||||
keystore = Keystore(tmp_path / "data.keys")
|
|
||||||
old_token = keystore.encrypt("alt", aad="x")
|
|
||||||
new_id = keystore.rotate()
|
|
||||||
assert new_id == "2"
|
|
||||||
assert keystore.decrypt(old_token, aad="x") == "alt"
|
|
||||||
assert keystore.needs_reencrypt(old_token) is True
|
|
||||||
new_token = keystore.encrypt("neu", aad="x")
|
|
||||||
assert keystore.needs_reencrypt(new_token) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_keystore_file_is_private(tmp_path):
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
|
|
||||||
path = tmp_path / "data.keys"
|
|
||||||
Keystore(path)
|
|
||||||
if os.name == "posix":
|
|
||||||
mode = path.stat().st_mode
|
|
||||||
assert not mode & (stat.S_IRGRP | stat.S_IROTH)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tampered_ciphertext_rejected(tmp_path):
|
|
||||||
keystore = Keystore(tmp_path / "data.keys")
|
|
||||||
token = keystore.encrypt("geheim", aad="x")
|
|
||||||
prefix, key_id, nonce, ciphertext = token.split(".", 3)
|
|
||||||
broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA"
|
|
||||||
with pytest.raises(DecryptionError):
|
|
||||||
keystore.decrypt(broken, aad="x")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Passwoerter und TOTP
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_argon2_hash_and_verify():
|
|
||||||
stored = passwords.hash_password("Ein-gutes-Passwort-1")
|
|
||||||
assert stored.startswith("$argon2id$")
|
|
||||||
assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True
|
|
||||||
assert passwords.verify_password(stored, "falsch") is False
|
|
||||||
assert passwords.needs_rehash(stored) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash():
|
|
||||||
bcrypt = pytest.importorskip("bcrypt")
|
|
||||||
legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode()
|
|
||||||
assert passwords.is_legacy_hash(legacy) is True
|
|
||||||
assert passwords.verify_password(legacy, "Altes-Passwort-1") is True
|
|
||||||
assert passwords.needs_rehash(legacy) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_normalisation_across_platforms():
|
|
||||||
import unicodedata
|
|
||||||
|
|
||||||
nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1")
|
|
||||||
nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1")
|
|
||||||
assert nfd != nfc
|
|
||||||
stored = passwords.hash_password(nfd)
|
|
||||||
assert passwords.verify_password(stored, nfc) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_policy_rules():
|
|
||||||
policy = passwords.PasswordPolicy(min_length=12)
|
|
||||||
assert policy.check("Ein-gutes-Passwort-1") == []
|
|
||||||
assert any("Zeichen erforderlich" in p for p in policy.check("kurz"))
|
|
||||||
assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa"))
|
|
||||||
assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester"))
|
|
||||||
assert any("haeufig" in p for p in policy.check("passwort1234"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_totp_generates_and_verifies():
|
|
||||||
secret = totp.generate_secret()
|
|
||||||
code = totp.current_code(secret)
|
|
||||||
assert totp.verify_code(secret, code) is True
|
|
||||||
assert totp.verify_code(secret, "000000") is False
|
|
||||||
assert totp.verify_code(secret, code, now=0) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_totp_drift_tolerance():
|
|
||||||
secret = totp.generate_secret()
|
|
||||||
now = 1_700_000_000
|
|
||||||
previous = totp.code_at(secret, int(now // totp.PERIOD) - 1)
|
|
||||||
assert totp.verify_code(secret, previous, now=now) is True
|
|
||||||
far = totp.code_at(secret, int(now // totp.PERIOD) - 5)
|
|
||||||
assert totp.verify_code(secret, far, now=now) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_totp_uri_contains_issuer_and_secret():
|
|
||||||
secret = totp.generate_secret()
|
|
||||||
uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test")
|
|
||||||
assert uri.startswith("otpauth://totp/")
|
|
||||||
assert secret in uri
|
|
||||||
assert "issuer=TESM%20Test" in uri
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Aenderungsprotokoll
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_chain_detects_modification(core_app):
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
for index in range(5):
|
|
||||||
audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}")
|
|
||||||
assert audit.verify_chain(conn)["ok"] is True
|
|
||||||
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3")
|
|
||||||
result = audit.verify_chain(conn)
|
|
||||||
assert result["ok"] is False
|
|
||||||
assert result["broken_at"] == 3
|
|
||||||
assert "Hash" in result["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_chain_detects_deletion(core_app):
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
for index in range(5):
|
|
||||||
audit.log(conn, actor="tester", action="test.event", target=str(index))
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
conn.execute("DELETE FROM audit_log WHERE id=3")
|
|
||||||
result = audit.verify_chain(conn)
|
|
||||||
assert result["ok"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_archiving_moves_full_days(core_app, tmp_path):
|
|
||||||
extension = core(core_app)
|
|
||||||
old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
for index in range(40):
|
|
||||||
audit.log(
|
|
||||||
conn,
|
|
||||||
actor="tester",
|
|
||||||
action="test.old",
|
|
||||||
target=str(index),
|
|
||||||
ts=old_day + timedelta(seconds=index),
|
|
||||||
)
|
|
||||||
for index in range(5):
|
|
||||||
audit.log(conn, actor="tester", action="test.new", target=str(index))
|
|
||||||
with extension.database.transaction(conn):
|
|
||||||
written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5)
|
|
||||||
assert written
|
|
||||||
# Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der
|
|
||||||
# Startzeile, die die App beim Migrieren selbst geschrieben hat).
|
|
||||||
today = datetime.now(timezone.utc).date().isoformat()
|
|
||||||
remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log")
|
|
||||||
assert [row["day"] for row in remaining] == [today]
|
|
||||||
lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines()
|
|
||||||
assert len(lines) == 40
|
|
||||||
assert json.loads(lines[0])["action"] == "test.old"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Sitzungen
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_id_rotates_on_login(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
client.get("/login")
|
|
||||||
before = client.get_cookie("tesm_session")
|
|
||||||
login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
after = client.get_cookie("tesm_session")
|
|
||||||
assert after is not None
|
|
||||||
if before is not None:
|
|
||||||
assert before.value != after.value
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login):
|
|
||||||
from tesm_core.security import sessions as session_store
|
|
||||||
|
|
||||||
row = make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
assert client.get("/konto/").status_code == 200
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
||||||
session_store.revoke_all_for_user(conn, int(row["id"]), reason="test")
|
|
||||||
|
|
||||||
response = client.get("/konto/")
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert "/login" in response.headers["Location"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_password_change_revokes_other_sessions(core_app, make_user):
|
|
||||||
from tesm_core.security import sessions as session_store
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
row = make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
first = core_app.test_client()
|
|
||||||
second = core_app.test_client()
|
|
||||||
for client_instance in (first, second):
|
|
||||||
page = client_instance.get("/login")
|
|
||||||
body = page.get_data(as_text=True)
|
|
||||||
marker = 'name="csrf_token" value="'
|
|
||||||
start = body.index(marker) + len(marker)
|
|
||||||
token = body[start : body.index('"', start)]
|
|
||||||
client_instance.post(
|
|
||||||
"/login",
|
|
||||||
data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
assert len(session_store.active_for_user(conn, int(row["id"]))) == 2
|
|
||||||
|
|
||||||
page = first.get("/konto/sicherheit")
|
|
||||||
body = page.get_data(as_text=True)
|
|
||||||
marker = 'name="csrf_token" value="'
|
|
||||||
start = body.index(marker) + len(marker)
|
|
||||||
token = body[start : body.index('"', start)]
|
|
||||||
first.post(
|
|
||||||
"/konto/passwort",
|
|
||||||
data={
|
|
||||||
"current_password": "Ein-gutes-Passwort-1",
|
|
||||||
"new_password": "Neues-Passwort-2026",
|
|
||||||
"repeat_password": "Neues-Passwort-2026",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert second.get("/konto/").status_code == 302
|
|
||||||
assert first.get("/konto/").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
|
|
||||||
# Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen.
|
|
||||||
from flask import session as flask_session
|
|
||||||
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat(
|
|
||||||
timespec="seconds"
|
|
||||||
)
|
|
||||||
token = csrf_token("/konto/sicherheit")
|
|
||||||
response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token})
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert "/bestaetigen" in response.headers["Location"]
|
|
||||||
assert flask_session is not None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Der Rechteweg muss tatsaechlich begehbar sein
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_units_do_not_disable_the_privilege_path():
|
|
||||||
"""``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer.
|
|
||||||
|
|
||||||
Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede
|
|
||||||
Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte:
|
|
||||||
nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben.
|
|
||||||
Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen
|
|
||||||
sollte.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent / "deploy" / "systemd"
|
|
||||||
units = list(root.glob("*.service"))
|
|
||||||
assert units
|
|
||||||
for unit in units:
|
|
||||||
text = unit.read_text(encoding="utf-8")
|
|
||||||
assert "NoNewPrivileges=yes" not in text, (
|
|
||||||
f"{unit.name}: verhindert sudo und damit jede Systemaktion."
|
|
||||||
)
|
|
||||||
# Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit
|
|
||||||
# "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW.
|
|
||||||
for line in text.splitlines():
|
|
||||||
if line.startswith("CapabilityBoundingSet="):
|
|
||||||
raise AssertionError(
|
|
||||||
f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID."
|
|
||||||
)
|
|
||||||
# Die eigentliche Grenze bleibt bestehen.
|
|
||||||
# "strict" sperrt auch den Helfer aus /etc aus -- gemessen.
|
|
||||||
assert "ProtectSystem=full" in text, unit.name
|
|
||||||
assert "ProtectSystem=strict" not in text, (
|
|
||||||
f"{unit.name}: strict sperrt den Helfer aus /etc aus."
|
|
||||||
)
|
|
||||||
assert "PrivateTmp=yes" in text, unit.name
|
|
||||||
assert "User=root" not in text, unit.name
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_helper_is_probed_not_just_looked_for():
|
|
||||||
""""Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt."""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from tesm_core.sysops import SysOps
|
|
||||||
|
|
||||||
assert hasattr(SysOps, "selftest")
|
|
||||||
source = inspect.getsource(SysOps.selftest)
|
|
||||||
assert 'self.run("service-status", "nginx")' in source
|
|
||||||
assert 'self.run("nginx-test")' not in source, (
|
|
||||||
"Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'."
|
|
||||||
)
|
|
||||||
# Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt.
|
|
||||||
assert "no new privileges" in source
|
|
||||||
assert "a password is required" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_selftest_explains_a_blocked_sudo(monkeypatch):
|
|
||||||
from tesm_core.sysops import Result, SysOps
|
|
||||||
|
|
||||||
class Blocked(SysOps):
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def run(self, verb, *args, stdin="", timeout=None):
|
|
||||||
return Result(
|
|
||||||
False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1
|
|
||||||
)
|
|
||||||
|
|
||||||
result = Blocked(app_key="tesm").selftest()
|
|
||||||
assert not result.ok
|
|
||||||
assert "NoNewPrivileges" in result.message
|
|
||||||
assert "systemctl" in result.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_selftest_reports_a_missing_sudoers_rule():
|
|
||||||
from tesm_core.sysops import Result, SysOps
|
|
||||||
|
|
||||||
class NeedsPassword(SysOps):
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def run(self, verb, *args, stdin="", timeout=None):
|
|
||||||
return Result(False, "", "sudo: a password is required", 1)
|
|
||||||
|
|
||||||
result = NeedsPassword(app_key="tesm").selftest()
|
|
||||||
assert not result.ok
|
|
||||||
assert "sudoers" in result.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_selftest_says_so_when_the_helper_is_absent(tmp_path):
|
|
||||||
from tesm_core.sysops import SysOps
|
|
||||||
|
|
||||||
result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest()
|
|
||||||
assert not result.ok
|
|
||||||
assert "fehlt" in result.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_keeps_directory_groups(core_app, monkeypatch):
|
|
||||||
"""Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen.
|
|
||||||
|
|
||||||
``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus
|
|
||||||
dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen.
|
|
||||||
Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine
|
|
||||||
Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test.
|
|
||||||
"""
|
|
||||||
from tesm_core.auth import directory, service
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
profil = directory.DirectoryProfile(
|
|
||||||
username="mitarbeiter",
|
|
||||||
dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local",
|
|
||||||
group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(directory, "is_enabled", lambda conn: True)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil)
|
|
||||||
)
|
|
||||||
|
|
||||||
with core_app.test_request_context("/login"):
|
|
||||||
with core(core_app).database.session() as conn:
|
|
||||||
outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1")
|
|
||||||
|
|
||||||
assert outcome.ok, outcome.message
|
|
||||||
assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen"
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"""Rauchtest des Grundgeruests: Start, Anmeldung, Rechte, CSRF, Header."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_starts_and_health_ok(client):
|
|
||||||
response = client.get("/gesundheit")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.get_json()["status"] == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_renders_and_sets_csrf(client):
|
|
||||||
response = client.get("/login")
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
assert "Anmelden" in body
|
|
||||||
assert 'name="csrf_token"' in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_security_headers_present(client):
|
|
||||||
response = client.get("/login")
|
|
||||||
csp = response.headers["Content-Security-Policy"]
|
|
||||||
assert "default-src 'self'" in csp
|
|
||||||
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
|
|
||||||
assert re.search(r"nonce-[A-Za-z0-9_-]{10,}", csp)
|
|
||||||
assert response.headers["X-Frame-Options"] == "DENY"
|
|
||||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
|
||||||
assert "camera=()" in response.headers["Permissions-Policy"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_and_logout(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
response = login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Willkommen" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
page = client.get("/konto/")
|
|
||||||
assert page.status_code == 200
|
|
||||||
assert "anna" in page.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_password_is_generic(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
response = login("anna", "falsch")
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
assert "Benutzername oder Passwort ist falsch" in body
|
|
||||||
# Kein Hinweis darauf, dass das Konto existiert.
|
|
||||||
assert "gesperrt" not in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_user_same_message(client, login):
|
|
||||||
body = login("gibtsnicht", "irgendwas").get_data(as_text=True)
|
|
||||||
assert "Benutzername oder Passwort ist falsch" in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_locked_account_rejected(client, make_user, login):
|
|
||||||
make_user("gesperrt", "Ein-gutes-Passwort-1", is_locked=True)
|
|
||||||
body = login("gesperrt", "Ein-gutes-Passwort-1").get_data(as_text=True)
|
|
||||||
assert "gesperrt" in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_blocks_after_many_attempts(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
messages = [login("anna", "falsch").get_data(as_text=True) for _ in range(9)]
|
|
||||||
assert any("voruebergehend gesperrt" in body for body in messages)
|
|
||||||
|
|
||||||
|
|
||||||
def test_protected_page_redirects_to_login(client):
|
|
||||||
response = client.get("/widgets")
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert "/login" in response.headers["Location"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_required_blocks_without_grant(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
assert client.get("/verwaltung/benutzer").status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_granted_allows_access(client, make_user, login):
|
|
||||||
make_user("bob", "Ein-gutes-Passwort-1", permissions=("users.view",))
|
|
||||||
login("bob", "Ein-gutes-Passwort-1")
|
|
||||||
response = client.get("/verwaltung/benutzer")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Benutzer" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sees_everything(client, make_user, login):
|
|
||||||
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
login("root", "Ein-gutes-Passwort-1")
|
|
||||||
for path in ("/verwaltung/benutzer", "/verwaltung/gruppen", "/papierkorb/", "/diagnose/"):
|
|
||||||
assert client.get(path).status_code == 200, path
|
|
||||||
|
|
||||||
|
|
||||||
def test_csrf_missing_token_is_rejected(client, make_user, login):
|
|
||||||
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
login("root", "Ein-gutes-Passwort-1")
|
|
||||||
response = client.post("/verwaltung/benutzer/neu", data={"username": "boese"})
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "Sicherheitstoken" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_csrf_wrong_origin_is_rejected(client, make_user, login, csrf_token):
|
|
||||||
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
login("root", "Ein-gutes-Passwort-1")
|
|
||||||
token = csrf_token("/verwaltung/benutzer")
|
|
||||||
response = client.post(
|
|
||||||
"/verwaltung/benutzer/neu",
|
|
||||||
data={"username": "boese", "csrf_token": token},
|
|
||||||
headers={"Origin": "https://angreifer.example"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_nav_hides_pages_without_permission(client, make_user, login):
|
|
||||||
make_user("anna", "Ein-gutes-Passwort-1")
|
|
||||||
login("anna", "Ein-gutes-Passwort-1")
|
|
||||||
body = client.get("/").get_data(as_text=True)
|
|
||||||
assert "Diagnose" not in body
|
|
||||||
assert "Widgets" not in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_nav_shows_pages_with_permission(client, make_user, login):
|
|
||||||
make_user("bob", "Ein-gutes-Passwort-1", permissions=("widgets.view",))
|
|
||||||
login("bob", "Ein-gutes-Passwort-1")
|
|
||||||
body = client.get("/").get_data(as_text=True)
|
|
||||||
assert "Widgets" in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_group_created_from_preset(core_app):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.rbac import service as rbac
|
|
||||||
|
|
||||||
extension = core(core_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
groups = rbac.list_groups(conn)
|
|
||||||
names = {group.name for group in groups}
|
|
||||||
assert "Benutzer" in names
|
|
||||||
default = next(group for group in groups if group.name == "Benutzer")
|
|
||||||
assert default.is_default is True
|
|
||||||
assert "widgets.view" in default.permissions
|
|
||||||
assert "demo.view" in default.permissions # Bereichsschalter wird mitgesetzt
|
|
||||||
|
|
||||||
|
|
||||||
def test_error_page_shows_request_id(client):
|
|
||||||
response = client.get("/gibt-es-nicht")
|
|
||||||
assert response.status_code == 404
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
assert "404" in body
|
|
||||||
assert "Vorgangsnummer" in body
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Verzeichnisgruppen: suchen statt abtippen
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_group_search_is_open_to_fileshare_editors():
|
|
||||||
"""Wer Freigaben zuordnet, muss die Gruppen finden koennen.
|
|
||||||
|
|
||||||
Sonst braeuchte er zusaetzlich das Recht, den Verzeichnisdienst zu
|
|
||||||
konfigurieren -- eine Rechteausweitung fuer eine reine Lesesuche.
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from tesm_core.views import hostsettings
|
|
||||||
|
|
||||||
source = inspect.getsource(hostsettings.directory_group_search)
|
|
||||||
assert "fileshare.edit" in source
|
|
||||||
assert 'mode="any"' in source or "mode='any'" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_picker_never_forces_typing_a_dn():
|
|
||||||
"""Ein abgetippter DN ist ein Tippfehler, der erst bei der Anmeldung auffaellt."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent
|
|
||||||
picker = (
|
|
||||||
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
|
|
||||||
/ "tesm_core" / "_group_picker.html"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert "directory_group_search" in picker
|
|
||||||
assert "data-group-query" in picker
|
|
||||||
# Tippen bleibt trotzdem moeglich: ohne Dienstkonto gibt es keine Suche.
|
|
||||||
assert 'name="{{ dn_field }}"' in picker
|
|
||||||
|
|
||||||
for template in (
|
|
||||||
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
|
|
||||||
/ "tesm_core" / "settings_directory.html",
|
|
||||||
root / "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "fileshare_mappings.html",
|
|
||||||
):
|
|
||||||
text = template.read_text(encoding="utf-8")
|
|
||||||
assert "group_picker(" in text, template.name
|
|
||||||
|
|
||||||
|
|
||||||
def test_transport_choice_carries_its_port():
|
|
||||||
"""Umgestellter Transport mit altem Port ergibt einen Fehler ohne Aussage."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent
|
|
||||||
page = (
|
|
||||||
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
|
|
||||||
/ "tesm_core" / "settings_directory.html"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert 'data-behavior="transport-port"' in page
|
|
||||||
assert 'data-port-target="#ldap-port"' in page
|
|
||||||
|
|
||||||
script = (
|
|
||||||
root / "packages" / "tesm-core" / "src" / "tesm_core" / "static"
|
|
||||||
/ "tesm_core" / "js" / "app.js"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert "transport-port" in script
|
|
||||||
assert 'ldaps: "636"' in script
|
|
||||||
assert 'starttls: "389"' in script
|
|
||||||
# Ein absichtlich abweichender Port darf nicht ueberschrieben werden.
|
|
||||||
assert "known.includes(port.value)" in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_matrix_has_no_nameless_checkbox(client, make_user, login):
|
|
||||||
"""In der Rechtematrix darf kein Kaestchen ohne ``name`` stehen.
|
|
||||||
|
|
||||||
Fuer nicht vorgesehene Aktionen stand dort ein deaktiviertes Kaestchen.
|
|
||||||
Das JS hat beim Einschalten des Bereichs *alle* Kaestchen der Zeile
|
|
||||||
freigegeben -- auch dieses. Es liess sich anhaken, uebertrug wegen des
|
|
||||||
fehlenden ``name`` aber nichts: es sah erteilt aus und war nach dem
|
|
||||||
Speichern verschwunden. Ohne Eingabefeld kann das nicht wieder passieren.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
||||||
login("root", "Ein-gutes-Passwort-1")
|
|
||||||
body = client.get("/verwaltung/gruppen").get_data(as_text=True)
|
|
||||||
if "permission-matrix" not in body:
|
|
||||||
gruppe = re.search(r'href="(/verwaltung/gruppen/\d+)"', body)
|
|
||||||
assert gruppe, "keine Gruppe zum Pruefen gefunden"
|
|
||||||
body = client.get(gruppe.group(1)).get_data(as_text=True)
|
|
||||||
|
|
||||||
assert "permission-matrix" in body, "Rechtematrix nicht gefunden"
|
|
||||||
kaestchen = re.findall(r"<input[^>]*type=\"checkbox\"[^>]*>", body)
|
|
||||||
assert kaestchen, "keine Kaestchen in der Matrix"
|
|
||||||
ohne_namen = [k for k in kaestchen if "name=" not in k]
|
|
||||||
assert not ohne_namen, f"Kaestchen ohne name: {ohne_namen[:3]}"
|
|
||||||
# Der Platzhalter ist jetzt reine Darstellung.
|
|
||||||
assert "matrix__na" in body
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
"""Der Weg vom alten TESM in dieses: Uebersetzer und Import zusammen.
|
|
||||||
|
|
||||||
Die Testdatei wird **genau so** gebaut, wie der Vorgaenger sie schreibt
|
|
||||||
(PBKDF2-HMAC-SHA256, 390000 Runden, Fernet, derselbe Umschlag). Damit prueft
|
|
||||||
der Test das echte Format und nicht meine Vorstellung davon.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("cryptography")
|
|
||||||
|
|
||||||
LEGACY_PASS = "alte-passphrase-2026"
|
|
||||||
NEW_PASS = "neue-passphrase-2026"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tesm_app(instance_env: Any):
|
|
||||||
from tesm import create_app
|
|
||||||
|
|
||||||
return create_app(TESTING=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
|
|
||||||
"""Baut einen Umschlag wie ``export_data()`` im alten TESM."""
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
|
|
||||||
salt = secrets.token_bytes(16)
|
|
||||||
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
|
|
||||||
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
|
||||||
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"app": "tesm",
|
|
||||||
"version": 2,
|
|
||||||
"exported_at": "2026-09-02 12:00:00",
|
|
||||||
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
|
||||||
"payload": token.decode("utf-8"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#: Ein Ausschnitt, der die Eigenheiten des alten Formats abdeckt: Switch- und
|
|
||||||
#: Zugangsdatenbezug per Name, geschachtelte DHCP-Optionswerte, "name" statt
|
|
||||||
#: "hostname" an der Reservierung, sowie Abschnitte, die nicht uebersetzt
|
|
||||||
#: werden koennen.
|
|
||||||
LEGACY_PAYLOAD: dict[str, Any] = {
|
|
||||||
"credentials": [
|
|
||||||
{"name": "Switch-Admin", "username": "manager", "password": "geheim-1", "category": "switch"},
|
|
||||||
{"name": "Linux-Root", "username": "root", "password": "geheim-2", "category": "linux"},
|
|
||||||
{"name": "Windows-Dienst", "username": "svc", "password": "geheim-3", "category": "windows"},
|
|
||||||
],
|
|
||||||
"switches": [
|
|
||||||
{"hostname": "sw-keller", "ip": "192.168.80.2", "ssh_port": 22, "credential_name": "Switch-Admin"},
|
|
||||||
],
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"mac": "4c:52:62:25:b9:e4",
|
|
||||||
"ip": "192.168.80.137",
|
|
||||||
"port": "12",
|
|
||||||
"name": "Autodarts",
|
|
||||||
"switch_hostname": "sw-keller",
|
|
||||||
"is_active": 1,
|
|
||||||
"ssh_port": 22,
|
|
||||||
"credential_name": "Linux-Root",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mac": "aa:bb:cc:00:00:02",
|
|
||||||
"ip": "192.168.80.50",
|
|
||||||
"port": "3",
|
|
||||||
"name": "Kasse",
|
|
||||||
"switch_hostname": "sw-keller",
|
|
||||||
"is_active": 1,
|
|
||||||
"ssh_port": 22,
|
|
||||||
"credential_name": "Windows-Dienst",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"dhcp": {
|
|
||||||
"settings": {"dhcp_domain": "firma.local", "dhcp_lease_default": "1200"},
|
|
||||||
"subnets": [
|
|
||||||
{
|
|
||||||
"interface": "eth0",
|
|
||||||
"range_start": "192.168.80.100",
|
|
||||||
"range_end": "192.168.80.200",
|
|
||||||
"gateway": "192.168.80.1",
|
|
||||||
"dns": "192.168.80.1",
|
|
||||||
"sort_order": 0,
|
|
||||||
"enabled": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"reservations": [{"mac": "aa:bb:cc:00:00:09", "ip": "192.168.80.90", "name": "drucker"}],
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"code": 225,
|
|
||||||
"name": "url",
|
|
||||||
"type": "string",
|
|
||||||
"description": "Terminal-URL",
|
|
||||||
"is_standard": 0,
|
|
||||||
"values": [
|
|
||||||
{"device_mac": "4c:52:62:25:b9:e4", "value": "https://play.autodarts.com"},
|
|
||||||
{"device_mac": None, "value": "https://intranet.firma.local"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# Ohne Wert gibt es nichts zu senden -- wird gemeldet, nicht uebernommen.
|
|
||||||
{"code": 226, "name": "leer", "type": "string", "description": "", "is_standard": 0, "values": []},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# Diese Abschnitte kann der Uebersetzer nicht abbilden.
|
|
||||||
"users": [{"username": "anna", "password_hash": "$2b$12$abcdefghijklmnopqrstuv"}],
|
|
||||||
"known_hosts": "192.168.80.2 ssh-ed25519 AAAA...\n",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_envelope_needs_the_right_passphrase():
|
|
||||||
from tesm.services.legacy_transfer import LegacyTransferError, open_legacy
|
|
||||||
|
|
||||||
datei = _seal_legacy({"credentials": []}, LEGACY_PASS)
|
|
||||||
assert open_legacy(datei, LEGACY_PASS) == {"credentials": []}
|
|
||||||
with pytest.raises(LegacyTransferError, match="Passphrase"):
|
|
||||||
open_legacy(datei, "falsch")
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_envelope_of_another_app_is_refused():
|
|
||||||
from tesm.services.legacy_transfer import LegacyTransferError, open_legacy
|
|
||||||
|
|
||||||
fremd = json.dumps({"app": "etwas-anderes", "salt": "AAAA", "payload": "x"})
|
|
||||||
with pytest.raises(LegacyTransferError, match="nicht zu TESM"):
|
|
||||||
open_legacy(fremd, LEGACY_PASS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_report_names_what_it_cannot_translate():
|
|
||||||
"""Nicht uebersetzbare Abschnitte muessen benannt werden, nicht verschwinden."""
|
|
||||||
from tesm.services.legacy_transfer import convert, open_legacy
|
|
||||||
|
|
||||||
_, bericht = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS))
|
|
||||||
text = bericht.as_text()
|
|
||||||
assert "bcrypt" in text, "der Hinweis zu lokalen Konten fehlt"
|
|
||||||
assert "Host-Schluessel" in text
|
|
||||||
assert "ohne Wert" in text, "die wertlose Option wurde nicht gemeldet"
|
|
||||||
assert bericht.counts["credentials"] == 3
|
|
||||||
assert bericht.counts["devices"] == 2
|
|
||||||
assert bericht.counts["dhcp-Optionswerte"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_credentials_are_reported(tesm_app):
|
|
||||||
"""Ein zugeordneter, aber nicht exportierter Zugang bleibt beim Import leer."""
|
|
||||||
from tesm.services.legacy_transfer import convert, open_legacy
|
|
||||||
|
|
||||||
ohne = {key: value for key, value in LEGACY_PAYLOAD.items() if key != "credentials"}
|
|
||||||
_, bericht = convert(open_legacy(_seal_legacy(ohne, LEGACY_PASS), LEGACY_PASS))
|
|
||||||
hinweise = " ".join(bericht.notes)
|
|
||||||
assert "Switch-Admin" in hinweise and "Linux-Root" in hinweise
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_round_trip_into_the_database(tesm_app):
|
|
||||||
"""Alte Datei -> Uebersetzer -> neues Format -> vorhandener Import."""
|
|
||||||
from tesm.services import transfer
|
|
||||||
from tesm.services.legacy_transfer import convert, open_legacy
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
|
|
||||||
nutzlast, _ = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS))
|
|
||||||
# Ueber Siegel und Entsiegeln, damit auch der neue Umschlag geprueft ist.
|
|
||||||
datei = transfer.seal(nutzlast, NEW_PASS)
|
|
||||||
entsiegelt, _kopf = transfer.unseal(datei, NEW_PASS)
|
|
||||||
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
transfer.apply_import(
|
|
||||||
conn,
|
|
||||||
ext.keystore,
|
|
||||||
entsiegelt,
|
|
||||||
categories=("credentials", "switches", "devices", "dhcp", "settings"),
|
|
||||||
set_setting=lambda key, value: ext.settings.set(conn, key, value, actor="test"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
# -- Zugangsdaten: Passwort muss wieder lesbar sein ------------------
|
|
||||||
from tesm.services import inventory
|
|
||||||
|
|
||||||
zugang = {c.name: c for c in inventory.list_credentials(conn)}
|
|
||||||
assert set(zugang) == {"Switch-Admin", "Linux-Root", "Windows-Dienst"}
|
|
||||||
assert zugang["Linux-Root"].category == "linux"
|
|
||||||
benutzer, passwort = inventory.credential_secret(
|
|
||||||
conn, ext.keystore, zugang["Linux-Root"].id
|
|
||||||
)
|
|
||||||
assert (benutzer, passwort) == ("root", "geheim-2")
|
|
||||||
|
|
||||||
# -- Switch und Verknuepfung ----------------------------------------
|
|
||||||
switch = conn.execute("SELECT * FROM switches WHERE hostname='sw-keller'").fetchone()
|
|
||||||
assert switch is not None
|
|
||||||
assert switch["ip"] == "192.168.80.2"
|
|
||||||
assert switch["credential_id"] == zugang["Switch-Admin"].id
|
|
||||||
|
|
||||||
# -- Clients: Switch, Port, Zugang, abgeleitete Kategorie ------------
|
|
||||||
geraet = conn.execute(
|
|
||||||
"SELECT * FROM devices WHERE mac='4c:52:62:25:b9:e4'"
|
|
||||||
).fetchone()
|
|
||||||
assert geraet["name"] == "Autodarts"
|
|
||||||
assert geraet["switch_id"] == switch["id"]
|
|
||||||
assert geraet["port"] == "12"
|
|
||||||
assert geraet["credential_id"] == zugang["Linux-Root"].id
|
|
||||||
assert geraet["category"] == "linux", "Kategorie aus den Zugangsdaten abgeleitet"
|
|
||||||
|
|
||||||
windows = conn.execute("SELECT * FROM devices WHERE mac='aa:bb:cc:00:00:02'").fetchone()
|
|
||||||
assert windows["category"] == "windows"
|
|
||||||
|
|
||||||
# -- DHCP: Subnetz, Reservierung, Optionswerte ----------------------
|
|
||||||
subnetz = conn.execute("SELECT * FROM dhcp_subnets").fetchone()
|
|
||||||
assert (subnetz["range_start"], subnetz["range_end"]) == (
|
|
||||||
"192.168.80.100",
|
|
||||||
"192.168.80.200",
|
|
||||||
)
|
|
||||||
reservierung = conn.execute("SELECT * FROM dhcp_reservations").fetchone()
|
|
||||||
assert reservierung["hostname"] == "drucker", "alt 'name' -> neu 'hostname'"
|
|
||||||
|
|
||||||
option = conn.execute("SELECT * FROM dhcp_options WHERE code=225").fetchone()
|
|
||||||
assert option is not None and option["name"] == "url"
|
|
||||||
werte = {
|
|
||||||
row["device_id"]: row["value"]
|
|
||||||
for row in conn.execute(
|
|
||||||
"SELECT device_id, value FROM dhcp_option_values WHERE option_id=?",
|
|
||||||
(option["id"],),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
# Ein globaler Wert und einer, der an genau diesem Geraet haengt.
|
|
||||||
assert werte[None] == "https://intranet.firma.local"
|
|
||||||
assert werte[geraet["id"]] == "https://play.autodarts.com"
|
|
||||||
|
|
||||||
# -- Einstellungen: nur die DHCP-Schluessel -------------------------
|
|
||||||
assert ext.settings.get(conn, "dhcp_domain") == "firma.local"
|
|
||||||
|
|
||||||
|
|
||||||
def test_option_value_of_an_unknown_device_is_not_made_global(tesm_app):
|
|
||||||
"""Ein Wert fuer ein unbekanntes Geraet darf nicht an alle gehen.
|
|
||||||
|
|
||||||
Sonst wuerde aus einer geraetespezifischen Terminal-URL beim Import eine,
|
|
||||||
die jeder Client bekommt -- die stillste Art, eine Konfiguration zu
|
|
||||||
verfaelschen.
|
|
||||||
"""
|
|
||||||
from tesm.services import transfer
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
nutzlast = {
|
|
||||||
"dhcp": {
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"code": 225,
|
|
||||||
"name": "url",
|
|
||||||
"type": "string",
|
|
||||||
"description": "",
|
|
||||||
"is_standard": 0,
|
|
||||||
"value": "https://nur-fuer-eines",
|
|
||||||
"device_mac": "ff:ff:ff:ff:ff:ff",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
transfer.apply_import(
|
|
||||||
conn,
|
|
||||||
ext.keystore,
|
|
||||||
nutzlast,
|
|
||||||
categories=("dhcp",),
|
|
||||||
set_setting=lambda key, value: None,
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
assert conn.execute("SELECT COUNT(*) FROM dhcp_option_values").fetchone()[0] == 0
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
"""Phase-0-Tests: das Lizenzprotokoll isoliert, ohne Flask, ohne Datenbank.
|
|
||||||
|
|
||||||
Deckt bewusst auch die Angriffsfaelle ab, die im Vorgaengerprojekt nicht
|
|
||||||
abgesichert waren: Signatur-Wiederverwendung ueber Domaenengrenzen, Replay
|
|
||||||
alter Anfragen/Antworten, Downgrade eines nachgelieferten Lizenz-Updates und
|
|
||||||
Ausstellung durch einen nicht gepinnten Aussteller.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import tesm_licensing as lic
|
|
||||||
|
|
||||||
|
|
||||||
VENDOR = {
|
|
||||||
"name": "WiS GmbH",
|
|
||||||
"phone": "+49 000 000",
|
|
||||||
"email": "lizenzen@example.invalid",
|
|
||||||
"address": "Musterweg 1, 12345 Musterstadt",
|
|
||||||
"logo_base64": "",
|
|
||||||
}
|
|
||||||
CUSTOMER = {"name": "Beispiel AG", "contact_email": "it@example.invalid", "customer_ref": "K-1"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def master_keys() -> tuple[str, str]:
|
|
||||||
return lic.generate_keypair()
|
|
||||||
|
|
||||||
|
|
||||||
def issue(master_keys, **overrides):
|
|
||||||
priv, pub = master_keys
|
|
||||||
params = dict(
|
|
||||||
customer=CUSTOMER,
|
|
||||||
license_type="standard",
|
|
||||||
modules=[],
|
|
||||||
valid_days=365,
|
|
||||||
master_private_key_b64=priv,
|
|
||||||
master_public_key_b64=pub,
|
|
||||||
master_endpoint="https://lizenz.example.invalid/",
|
|
||||||
vendor=VENDOR,
|
|
||||||
)
|
|
||||||
params.update(overrides)
|
|
||||||
return lic.issue_license(**params)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Lizenzdatei
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_issued_license_verifies_and_splits(master_keys):
|
|
||||||
_, pub = master_keys
|
|
||||||
bundle, client_pub = issue(master_keys, modules=["dhcp", "maintenance"])
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
lic.verify_license(document, trusted_master_keys=[pub])
|
|
||||||
assert document["license_pubkey"] == client_pub
|
|
||||||
assert lic.public_from_private(client_priv) == client_pub
|
|
||||||
# Der private Schluessel darf nicht im Lizenzdokument stehen.
|
|
||||||
assert "client_key" not in document
|
|
||||||
assert "license_privkey" not in document
|
|
||||||
|
|
||||||
status = lic.license_status(document, issuer_pinned=True)
|
|
||||||
assert status.modules == {"dhcp", "maintenance"}
|
|
||||||
assert status.modules_active is True
|
|
||||||
assert status.expired is False
|
|
||||||
assert status.days_left == 365
|
|
||||||
assert status.warnings == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_tampered_field_breaks_signature(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
document["modules"] = ["dhcp", "fileshare", "maintenance"]
|
|
||||||
with pytest.raises(lic.SignatureInvalid):
|
|
||||||
lic.verify_license(document)
|
|
||||||
|
|
||||||
|
|
||||||
def test_swapped_master_key_is_detected(master_keys):
|
|
||||||
"""Ein Angreifer, der Payload *und* master_pubkey ersetzt, faellt am Pinning auf."""
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
evil_priv, evil_pub = lic.generate_keypair()
|
|
||||||
forged = lic.resign_license(
|
|
||||||
document, master_private_key_b64=evil_priv, changes={"modules": list(lic.ALL_MODULES)}
|
|
||||||
)
|
|
||||||
forged["master_pubkey"] = evil_pub
|
|
||||||
forged["master_key_id"] = lic.key_id(evil_pub)
|
|
||||||
forged["signature"] = lic.sign(
|
|
||||||
{k: v for k, v in forged.items() if k != "signature"},
|
|
||||||
evil_priv,
|
|
||||||
lic.SIG_CONTEXT_LICENSE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# In sich konsistent -- genau das war die Schwaeche von V1.
|
|
||||||
lic.verify_license(forged)
|
|
||||||
# Mit gepinntem Aussteller fliegt es auf.
|
|
||||||
_, real_pub = master_keys
|
|
||||||
with pytest.raises(lic.UntrustedIssuer):
|
|
||||||
lic.verify_license(forged, trusted_master_keys=[real_pub])
|
|
||||||
|
|
||||||
|
|
||||||
def test_key_id_mismatch_rejected(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
document["master_key_id"] = "0" * 16
|
|
||||||
with pytest.raises(lic.InvalidLicenseFile):
|
|
||||||
lic.verify_license(document)
|
|
||||||
|
|
||||||
|
|
||||||
def test_license_signature_not_reusable_as_response(master_keys):
|
|
||||||
"""Domaenentrennung: Lizenz-Signatur darf nicht als Antwort-Signatur gelten."""
|
|
||||||
_, pub = master_keys
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
|
||||||
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_LICENSE) is True
|
|
||||||
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_RESPONSE) is False
|
|
||||||
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_REQUEST) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_lifetime_license(master_keys):
|
|
||||||
bundle, _ = issue(master_keys, license_type="enterprise", lifetime=True, valid_days=0)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
lic.verify_license(document)
|
|
||||||
status = lic.license_status(document, issuer_pinned=True)
|
|
||||||
assert status.lifetime is True
|
|
||||||
assert status.expires_at is not None
|
|
||||||
assert status.days_left is None
|
|
||||||
assert status.expired is False
|
|
||||||
assert status.modules_active is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_trial_lifetime_forbidden(master_keys):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
issue(master_keys, license_type="trial", lifetime=True, valid_days=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_license_server_role_rejects_modules(master_keys):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
issue(master_keys, role="license_server", modules=["dhcp"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_grace_period_and_expiry(master_keys):
|
|
||||||
bundle, _ = issue(master_keys, valid_days=10, modules=["dhcp"])
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
issued = lic.parse_iso(document["issued_at"])
|
|
||||||
|
|
||||||
warn = lic.license_status(document, now=issued + timedelta(days=9), issuer_pinned=True)
|
|
||||||
assert warn.expiring_soon is True and warn.expired is False and warn.modules_active is True
|
|
||||||
|
|
||||||
grace = lic.license_status(document, now=issued + timedelta(days=25), issuer_pinned=True)
|
|
||||||
assert grace.expired is True
|
|
||||||
assert grace.grace_active is True
|
|
||||||
assert grace.modules_active is True # Kulanz laeuft noch
|
|
||||||
assert grace.days_since_expiry == 15
|
|
||||||
|
|
||||||
dead = lic.license_status(document, now=issued + timedelta(days=45), issuer_pinned=True)
|
|
||||||
assert dead.grace_active is False
|
|
||||||
assert dead.modules_active is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_before_blocks_future_license(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
issued = lic.parse_iso(document["issued_at"])
|
|
||||||
status = lic.license_status(document, now=issued - timedelta(hours=1), issuer_pinned=True)
|
|
||||||
assert status.not_yet_valid is True
|
|
||||||
assert status.modules_active is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_without_pinning_warns(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
status = lic.license_status(document)
|
|
||||||
assert any("gepinnt" in w for w in status.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_role_defaults_to_customer_when_missing(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, _ = lic.split_bundle(bundle)
|
|
||||||
document.pop("role")
|
|
||||||
assert lic.license_status(document).role == "customer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_bundle_compatibility(master_keys):
|
|
||||||
"""Eine alte Datei mit eingebettetem license_privkey laesst sich noch aufteilen."""
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
legacy = {**document, "license_privkey": client_priv}
|
|
||||||
doc2, priv2 = lic.split_bundle(legacy)
|
|
||||||
assert priv2 == client_priv
|
|
||||||
assert "license_privkey" not in doc2
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Aktivierungsprotokoll
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_activation_round_trip(master_keys):
|
|
||||||
master_priv, master_pub = master_keys
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
request = lic.build_client_request("activate", document, client_priv, hostname="tesm-kunde")
|
|
||||||
lic.verify_client_request(request, license_pub, expected_action="activate")
|
|
||||||
|
|
||||||
response = lic.build_master_response(
|
|
||||||
"activate",
|
|
||||||
license_id=request["license_id"],
|
|
||||||
fingerprint=request["fingerprint"],
|
|
||||||
request_nonce=request["nonce"],
|
|
||||||
master_private_key=master_priv,
|
|
||||||
)
|
|
||||||
lic.verify_master_response(
|
|
||||||
response,
|
|
||||||
master_pub,
|
|
||||||
expected_license_id=document["license_id"],
|
|
||||||
expected_fingerprint=request["fingerprint"],
|
|
||||||
expected_action="activate",
|
|
||||||
expected_nonce=request["nonce"],
|
|
||||||
current_seq=document["seq"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_offline_code_round_trip_is_identical(master_keys):
|
|
||||||
master_priv, master_pub = master_keys
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
request = lic.build_client_request("activate", document, client_priv)
|
|
||||||
code = lic.encode_code(request)
|
|
||||||
# Ein per Mail transportierter Code darf Umbrueche/Whitespace enthalten.
|
|
||||||
wrapped = "\n".join(code[i : i + 48] for i in range(0, len(code), 48))
|
|
||||||
assert lic.decode_code(wrapped) == request
|
|
||||||
lic.verify_client_request(lic.decode_code(wrapped), license_pub)
|
|
||||||
|
|
||||||
response = lic.build_master_response(
|
|
||||||
"activate",
|
|
||||||
license_id=request["license_id"],
|
|
||||||
fingerprint=request["fingerprint"],
|
|
||||||
request_nonce=request["nonce"],
|
|
||||||
master_private_key=master_priv,
|
|
||||||
)
|
|
||||||
lic.verify_master_response(
|
|
||||||
lic.decode_code(lic.encode_code(response)),
|
|
||||||
master_pub,
|
|
||||||
expected_license_id=document["license_id"],
|
|
||||||
expected_fingerprint=request["fingerprint"],
|
|
||||||
expected_action="activate",
|
|
||||||
expected_nonce=request["nonce"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_signature_requires_correct_license_key(master_keys):
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
other_priv, other_pub = lic.generate_keypair()
|
|
||||||
request = lic.build_client_request("heartbeat", document, client_priv)
|
|
||||||
with pytest.raises(lic.SignatureInvalid):
|
|
||||||
lic.verify_client_request(request, other_pub)
|
|
||||||
assert other_priv # nur zur Vollstaendigkeit
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_replay_is_rejected(master_keys):
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
request = lic.build_client_request("activate", document, client_priv)
|
|
||||||
|
|
||||||
used: set[str] = set()
|
|
||||||
|
|
||||||
def seen(nonce: str) -> bool:
|
|
||||||
if nonce in used:
|
|
||||||
return True
|
|
||||||
used.add(nonce)
|
|
||||||
return False
|
|
||||||
|
|
||||||
lic.verify_client_request(request, license_pub, seen_nonce=seen)
|
|
||||||
with pytest.raises(lic.ReplayDetected):
|
|
||||||
lic.verify_client_request(request, license_pub, seen_nonce=seen)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_request_is_rejected(master_keys):
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
old = lic.utcnow() - timedelta(seconds=lic.REQUEST_MAX_AGE_SECONDS + 60)
|
|
||||||
request = lic.build_client_request("heartbeat", document, client_priv, now=old)
|
|
||||||
with pytest.raises(lic.ReplayDetected):
|
|
||||||
lic.verify_client_request(request, license_pub)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unsigned_request_does_not_burn_nonce(master_keys):
|
|
||||||
"""Nonce-Verbrauch erst nach gueltiger Signatur -- sonst waere die Nonce-Tabelle
|
|
||||||
ein kostenloser DoS-Vektor."""
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
request = lic.build_client_request("activate", document, client_priv)
|
|
||||||
request["signature"] = lic.sign(
|
|
||||||
{"boese": True}, lic.generate_keypair()[0], lic.SIG_CONTEXT_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
calls: list[str] = []
|
|
||||||
with pytest.raises(lic.SignatureInvalid):
|
|
||||||
lic.verify_client_request(request, license_pub, seen_nonce=lambda n: calls.append(n) or False)
|
|
||||||
assert calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_response_bound_to_request_nonce(master_keys):
|
|
||||||
master_priv, master_pub = master_keys
|
|
||||||
bundle, _ = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
first = lic.build_client_request("activate", document, client_priv)
|
|
||||||
response = lic.build_master_response(
|
|
||||||
"activate",
|
|
||||||
license_id=first["license_id"],
|
|
||||||
fingerprint=first["fingerprint"],
|
|
||||||
request_nonce=first["nonce"],
|
|
||||||
master_private_key=master_priv,
|
|
||||||
)
|
|
||||||
second = lic.build_client_request("activate", document, client_priv)
|
|
||||||
with pytest.raises(lic.ReplayDetected):
|
|
||||||
lic.verify_master_response(
|
|
||||||
response,
|
|
||||||
master_pub,
|
|
||||||
expected_license_id=document["license_id"],
|
|
||||||
expected_fingerprint=second["fingerprint"],
|
|
||||||
expected_action="activate",
|
|
||||||
expected_nonce=second["nonce"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_license_update_downgrade_rejected(master_keys):
|
|
||||||
master_priv, master_pub = master_keys
|
|
||||||
bundle, _ = issue(master_keys, modules=["dhcp"])
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
|
|
||||||
updated = lic.resign_license(
|
|
||||||
document, master_private_key_b64=master_priv, changes={"modules": ["dhcp", "fileshare"]}
|
|
||||||
)
|
|
||||||
assert updated["seq"] == document["seq"] + 1
|
|
||||||
lic.verify_license(updated, trusted_master_keys=[master_pub])
|
|
||||||
|
|
||||||
request = lic.build_client_request("heartbeat", updated, client_priv)
|
|
||||||
stale = lic.build_master_response(
|
|
||||||
"heartbeat",
|
|
||||||
license_id=request["license_id"],
|
|
||||||
fingerprint=request["fingerprint"],
|
|
||||||
request_nonce=request["nonce"],
|
|
||||||
master_private_key=master_priv,
|
|
||||||
license_update=document, # alte Revision
|
|
||||||
)
|
|
||||||
with pytest.raises(lic.ReplayDetected):
|
|
||||||
lic.verify_master_response(
|
|
||||||
stale,
|
|
||||||
master_pub,
|
|
||||||
expected_license_id=updated["license_id"],
|
|
||||||
expected_fingerprint=request["fingerprint"],
|
|
||||||
expected_action="heartbeat",
|
|
||||||
expected_nonce=request["nonce"],
|
|
||||||
current_seq=updated["seq"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_report_size_limit(master_keys):
|
|
||||||
bundle, license_pub = issue(master_keys, role="license_server")
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
report = {"customers": [{"name": f"K{i}"} for i in range(lic.MAX_REPORT_ROWS + 1)]}
|
|
||||||
request = lic.build_client_request("heartbeat", document, client_priv, report=report)
|
|
||||||
with pytest.raises(lic.ProtocolViolation):
|
|
||||||
lic.verify_client_request(request, license_pub)
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_fingerprint_and_nonce_rejected(master_keys):
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
base = lic.build_client_request("activate", document, client_priv)
|
|
||||||
|
|
||||||
bad_fp = copy.deepcopy(base)
|
|
||||||
bad_fp["fingerprint"] = "nicht-hex"
|
|
||||||
with pytest.raises(lic.ProtocolViolation):
|
|
||||||
lic.verify_client_request(bad_fp, license_pub)
|
|
||||||
|
|
||||||
bad_nonce = copy.deepcopy(base)
|
|
||||||
bad_nonce["nonce"] = "kurz"
|
|
||||||
with pytest.raises(lic.ProtocolViolation):
|
|
||||||
lic.verify_client_request(bad_nonce, license_pub)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_action_for_endpoint(master_keys):
|
|
||||||
bundle, license_pub = issue(master_keys)
|
|
||||||
document, client_priv = lic.split_bundle(bundle)
|
|
||||||
request = lic.build_client_request("heartbeat", document, client_priv)
|
|
||||||
with pytest.raises(lic.ProtocolViolation):
|
|
||||||
lic.verify_client_request(request, license_pub, expected_action="activate")
|
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_is_stable_and_hex():
|
|
||||||
first = lic.system_fingerprint()
|
|
||||||
assert first == lic.system_fingerprint()
|
|
||||||
assert len(first) == 64
|
|
||||||
int(first, 16)
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen.
|
|
||||||
|
|
||||||
Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus
|
|
||||||
einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar
|
|
||||||
("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo.
|
|
||||||
Beides faellt hier auf, bevor ein Paket gebaut wird.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
SKIP_DIRS = {
|
|
||||||
".git",
|
|
||||||
".venv",
|
|
||||||
"__pycache__",
|
|
||||||
"instance",
|
|
||||||
".pytest_cache",
|
|
||||||
".ruff_cache",
|
|
||||||
"node_modules",
|
|
||||||
}
|
|
||||||
TEXT_SUFFIXES = {
|
|
||||||
".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md",
|
|
||||||
".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf",
|
|
||||||
}
|
|
||||||
#: Dateien ohne Endung, die trotzdem Text sind.
|
|
||||||
EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"}
|
|
||||||
|
|
||||||
SECRET_NAMES = {
|
|
||||||
"data.keys",
|
|
||||||
"secret.key",
|
|
||||||
"license.json",
|
|
||||||
"license_key.json",
|
|
||||||
"master_signing_key.json",
|
|
||||||
"fernet.key",
|
|
||||||
"known_hosts",
|
|
||||||
}
|
|
||||||
SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"}
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_files() -> list[Path]:
|
|
||||||
files: list[Path] = []
|
|
||||||
for path in REPO_ROOT.rglob("*"):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts):
|
|
||||||
continue
|
|
||||||
files.append(path)
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def _text_files() -> list[Path]:
|
|
||||||
return [
|
|
||||||
path
|
|
||||||
for path in _repo_files()
|
|
||||||
if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_crlf_line_endings():
|
|
||||||
"""CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar."""
|
|
||||||
offenders = [
|
|
||||||
str(path.relative_to(REPO_ROOT))
|
|
||||||
for path in _text_files()
|
|
||||||
if b"\r\n" in path.read_bytes()
|
|
||||||
]
|
|
||||||
assert not offenders, (
|
|
||||||
"Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n "
|
|
||||||
+ "\n ".join(offenders)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_secrets_committed():
|
|
||||||
"""Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo."""
|
|
||||||
offenders = [
|
|
||||||
str(path.relative_to(REPO_ROOT))
|
|
||||||
for path in _repo_files()
|
|
||||||
if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES
|
|
||||||
]
|
|
||||||
assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_scripts_have_shebang_and_strict_mode():
|
|
||||||
scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"]
|
|
||||||
assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere."
|
|
||||||
for script in scripts:
|
|
||||||
text = script.read_text(encoding="utf-8")
|
|
||||||
assert text.startswith("#!"), f"{script.name}: Shebang fehlt"
|
|
||||||
assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_helper_validates_every_verb():
|
|
||||||
"""Jedes Verb des privilegierten Helfers muss im case-Block auftauchen.
|
|
||||||
|
|
||||||
Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die
|
|
||||||
Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder,
|
|
||||||
schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion.
|
|
||||||
"""
|
|
||||||
from tesm_core.sysops import HELPER_VERBS
|
|
||||||
|
|
||||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
||||||
# case-Muster koennen mehrere Verben mit | buendeln.
|
|
||||||
patterns: set[str] = set()
|
|
||||||
for line in helper.splitlines():
|
|
||||||
match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line)
|
|
||||||
if match:
|
|
||||||
patterns.update(match.group(1).split("|"))
|
|
||||||
missing = [verb for verb in HELPER_VERBS if verb not in patterns]
|
|
||||||
assert not missing, f"Verben fehlen im Helfer: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_helper_has_no_unquoted_argument_passthrough():
|
|
||||||
"""Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen."""
|
|
||||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
||||||
for forbidden in ("eval ", "bash -c", "sh -c"):
|
|
||||||
assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_helper_blocks_path_traversal_everywhere():
|
|
||||||
"""Jede Pfadpruefung im Helfer muss ".." abweisen.
|
|
||||||
|
|
||||||
Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen
|
|
||||||
unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den
|
|
||||||
zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``.
|
|
||||||
"""
|
|
||||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
||||||
checks = re.findall(
|
|
||||||
r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M
|
|
||||||
)
|
|
||||||
assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere."
|
|
||||||
for body in checks:
|
|
||||||
assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar")
|
|
||||||
def test_helper_accepts_instances_but_no_invented_keys():
|
|
||||||
"""Der Namensraum einer Instanz ist eng begrenzt.
|
|
||||||
|
|
||||||
Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/<key>``.
|
|
||||||
"""
|
|
||||||
script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
||||||
start = script.index("check_app_key() {")
|
|
||||||
end = script.index(chr(10) + "}", start) + 2
|
|
||||||
prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10)
|
|
||||||
snippet = prelude + script[start:end]
|
|
||||||
|
|
||||||
def accepted(key: str) -> bool:
|
|
||||||
command = snippet + chr(10) + 'check_app_key "' + key + '"'
|
|
||||||
return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0
|
|
||||||
|
|
||||||
for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"):
|
|
||||||
assert accepted(good), good
|
|
||||||
for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20):
|
|
||||||
assert not accepted(bad), bad
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_systemd_units_are_hardened():
|
|
||||||
units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service"))
|
|
||||||
assert units
|
|
||||||
for unit in units:
|
|
||||||
text = unit.read_text(encoding="utf-8")
|
|
||||||
assert "User=root" not in text, f"{unit.name} laeuft als root"
|
|
||||||
# ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten
|
|
||||||
# Helfer aus /etc aus -- gemessen, siehe test_core_security.py.
|
|
||||||
for directive in ("ProtectSystem=full", "PrivateTmp=yes"):
|
|
||||||
assert directive in text, f"{unit.name}: {directive} fehlt"
|
|
||||||
# NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und
|
|
||||||
# damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr
|
|
||||||
# aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau
|
|
||||||
# so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar".
|
|
||||||
assert "NoNewPrivileges=yes" not in text, (
|
|
||||||
f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sudoers_grants_only_the_helper():
|
|
||||||
for name in ("tesm", "tesm-license"):
|
|
||||||
raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8")
|
|
||||||
# Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den
|
|
||||||
# Test nicht ausloesen.
|
|
||||||
rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")]
|
|
||||||
body = "\n".join(rules)
|
|
||||||
assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel"
|
|
||||||
assert "/usr/local/lib/tesm/tesm-helper" in body
|
|
||||||
for line in rules:
|
|
||||||
if "NOPASSWD" in line:
|
|
||||||
assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), (
|
|
||||||
f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("app", ["tesm", "tesm-license"])
|
|
||||||
def test_nginx_template_serves_static_from_app_root(app: str):
|
|
||||||
"""Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte."""
|
|
||||||
text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8")
|
|
||||||
assert f"alias /srv/{app}/static/;" in text
|
|
||||||
assert f"alias /srv/{app}/static-core/;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_documentation_referenced_by_the_units_exists():
|
|
||||||
"""``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt.
|
|
||||||
|
|
||||||
Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der
|
|
||||||
Verweis suggeriert eine Anleitung, die niemand geschrieben hat.
|
|
||||||
"""
|
|
||||||
for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"):
|
|
||||||
for line in unit.read_text(encoding="utf-8").splitlines():
|
|
||||||
if not line.startswith("Documentation=file:"):
|
|
||||||
continue
|
|
||||||
target = line.split("file:", 1)[1].strip()
|
|
||||||
# /srv/<app>/docs/X -> docs/X im Repository
|
|
||||||
relative = target.split("/docs/", 1)[1]
|
|
||||||
assert (REPO_ROOT / "docs" / relative).is_file(), (
|
|
||||||
f"{unit.name} verweist auf {target}, docs/{relative} fehlt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_readme_links_point_to_existing_files():
|
|
||||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
|
||||||
for target in re.findall(r"\]\((docs/[^)#]+)\)", readme):
|
|
||||||
assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}"
|
|
||||||
|
|
||||||
|
|
||||||
def _templates() -> list[Path]:
|
|
||||||
return [
|
|
||||||
path
|
|
||||||
for path in _repo_files()
|
|
||||||
if path.suffix == ".html" and "templates" in path.parts
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_html_entities_inside_escaped_macro_calls():
|
|
||||||
"""Makros escapen ihren Text -- Entities darin erscheinen woertlich.
|
|
||||||
|
|
||||||
Auf der Wartungsseite stand deshalb ``„Linux-Client“`` als
|
|
||||||
sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise
|
|
||||||
im uebrigen Vorlagentext voellig richtig ist.
|
|
||||||
"""
|
|
||||||
# Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck
|
|
||||||
# verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen
|
|
||||||
# Tabellenmarkup als Treffer.
|
|
||||||
pattern = re.compile(
|
|
||||||
r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*"
|
|
||||||
r"(?:\|\s*safe\s*)?\}\}",
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
offenders: list[str] = []
|
|
||||||
for path in _templates():
|
|
||||||
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
|
||||||
call = match.group(0)
|
|
||||||
# Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch:
|
|
||||||
# das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber
|
|
||||||
# schon beim Einsetzen des Arguments. Auf der Geraeteseite stand
|
|
||||||
# deshalb woertlich "<em>und</em>" im Text.
|
|
||||||
if re.search(r"&[a-zA-Z]{2,10};|</?[a-z]{1,10}>", call):
|
|
||||||
offenders.append(
|
|
||||||
f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}"
|
|
||||||
)
|
|
||||||
assert not offenders, (
|
|
||||||
"HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n "
|
|
||||||
+ "\n ".join(offenders)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_regions_never_point_at_a_whole_page():
|
|
||||||
"""``data-refresh-url`` schreibt die Antwort in ``innerHTML``.
|
|
||||||
|
|
||||||
Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in
|
|
||||||
einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist
|
|
||||||
auf der Wartungsseite passiert: die Karte "Auftraege" holte sich
|
|
||||||
``maintenance.overview``, also sich selbst.
|
|
||||||
|
|
||||||
Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist.
|
|
||||||
"""
|
|
||||||
fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt")
|
|
||||||
pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'")
|
|
||||||
found = 0
|
|
||||||
for path in _templates():
|
|
||||||
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
|
||||||
endpoint = match.group(1)
|
|
||||||
found += 1
|
|
||||||
assert any(marker in endpoint.lower() for marker in fragment_markers), (
|
|
||||||
f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- "
|
|
||||||
"das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt."
|
|
||||||
)
|
|
||||||
assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere."
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_loop_treats_zero_as_off():
|
|
||||||
"""``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000."""
|
|
||||||
script = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "packages"
|
|
||||||
/ "tesm-core"
|
|
||||||
/ "src"
|
|
||||||
/ "tesm_core"
|
|
||||||
/ "static"
|
|
||||||
/ "tesm_core"
|
|
||||||
/ "js"
|
|
||||||
/ "app.js"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert "refreshInterval || " not in script
|
|
||||||
assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten."
|
|
||||||
|
|
||||||
|
|
||||||
def test_licensing_package_is_shared_not_copied():
|
|
||||||
"""Es darf keine zweite Kopie des Lizenzprotokolls geben.
|
|
||||||
|
|
||||||
Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` --
|
|
||||||
war im Vorgaenger die gefaehrlichste Invariante.
|
|
||||||
"""
|
|
||||||
copies = [
|
|
||||||
str(path.relative_to(REPO_ROOT))
|
|
||||||
for path in _repo_files()
|
|
||||||
if path.name == "licensing.py"
|
|
||||||
]
|
|
||||||
assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,968 +0,0 @@
|
|||||||
"""Rauchtest der TESM-Anwendung: jede Seite rendert, jede Aktion greift.
|
|
||||||
|
|
||||||
Deckt bewusst auch die Faelle ab, die im Vorgaengerprojekt kaputtgingen:
|
|
||||||
Rechteschranken, Lizenzgates auf Modulrouten, MAC-Normalisierung, und die
|
|
||||||
Trennung zwischen oeffentlicher und angemeldeter Statusuebersicht.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tesm_app(instance_env: Any):
|
|
||||||
from tesm import create_app
|
|
||||||
|
|
||||||
app = create_app(TESTING=True)
|
|
||||||
yield app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tclient(tesm_app: Any):
|
|
||||||
return tesm_app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(tesm_app: Any):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.security import passwords
|
|
||||||
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, "
|
|
||||||
"updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))",
|
|
||||||
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
||||||
)
|
|
||||||
return {"username": "root", "password": "Ein-gutes-Passwort-1"}
|
|
||||||
|
|
||||||
|
|
||||||
def _token(client: Any, path: str = "/login") -> str:
|
|
||||||
body = client.get(path).get_data(as_text=True)
|
|
||||||
for marker in ('name="csrf_token" value="', 'data-csrf="'):
|
|
||||||
if marker in body:
|
|
||||||
start = body.index(marker) + len(marker)
|
|
||||||
return body[start : body.index('"', start)]
|
|
||||||
raise AssertionError(f"Kein CSRF-Token in {path}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def login_admin(tclient: Any, admin: dict[str, str]):
|
|
||||||
def do() -> Any:
|
|
||||||
return tclient.post(
|
|
||||||
"/login",
|
|
||||||
data={
|
|
||||||
"username": admin["username"],
|
|
||||||
"password": admin["password"],
|
|
||||||
"csrf_token": _token(tclient),
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return do
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Start und Grundzustand
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_boots_and_migrations_applied(tesm_app):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
status = core(tesm_app).migration_status()
|
|
||||||
components = {row["component"] for row in status}
|
|
||||||
assert components == {"core", "tesm"}
|
|
||||||
names = {(row["component"], row["name"]) for row in status}
|
|
||||||
assert ("tesm", "baseline") in names
|
|
||||||
assert ("tesm", "seed-dhcp-options") in names
|
|
||||||
|
|
||||||
|
|
||||||
def test_dhcp_option_catalogue_seeded(tesm_app):
|
|
||||||
from tesm_core.db import Database
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
count = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE is_standard=1")
|
|
||||||
host_name = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE code=12")
|
|
||||||
assert count == 56
|
|
||||||
# host-name (12) ist bewusst nicht im Katalog -- Kea beantwortet das ueber
|
|
||||||
# das hostname-Feld der Reservierung.
|
|
||||||
assert host_name == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_role_presets_created(tesm_app):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.rbac import service as rbac
|
|
||||||
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
names = {group.name for group in rbac.list_groups(conn)}
|
|
||||||
assert {"Benutzer", "Betrieb", "Support", "Netzwerk", "Revision", "Administration"} <= names
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_dashboard_hides_details(tclient):
|
|
||||||
response = tclient.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
assert "Statusuebersicht" in body
|
|
||||||
assert "oeffentliche Ansicht" in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_endpoint(tclient):
|
|
||||||
assert tclient.get("/gesundheit").get_json()["status"] == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Seiten
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_PAGES = [
|
|
||||||
"/",
|
|
||||||
"/clients/",
|
|
||||||
"/switche/",
|
|
||||||
"/zugangsdaten/",
|
|
||||||
"/protokolle/laufend",
|
|
||||||
"/protokolle/verlauf",
|
|
||||||
"/protokolle/aenderungen",
|
|
||||||
"/protokolle/neustarts",
|
|
||||||
"/einstellungen/system",
|
|
||||||
"/einstellungen/netzwerk",
|
|
||||||
"/einstellungen/webserver",
|
|
||||||
"/einstellungen/verzeichnisdienst",
|
|
||||||
"/lizenz/",
|
|
||||||
"/sicherung/",
|
|
||||||
"/papierkorb/",
|
|
||||||
"/diagnose/",
|
|
||||||
"/verwaltung/benutzer",
|
|
||||||
"/verwaltung/gruppen",
|
|
||||||
"/verwaltung/sitzungen",
|
|
||||||
"/konto/",
|
|
||||||
"/konto/sicherheit",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ADMIN_PAGES)
|
|
||||||
def test_admin_pages_render(tclient, login_admin, path):
|
|
||||||
login_admin()
|
|
||||||
response = tclient.get(path)
|
|
||||||
assert response.status_code == 200, f"{path} -> {response.status_code}"
|
|
||||||
assert "Interner Fehler" not in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_licensed_modules_are_hidden_without_license(tclient, login_admin):
|
|
||||||
"""Ohne Lizenz sind DHCP, Wartung und Dateifreigaben komplett unerreichbar."""
|
|
||||||
login_admin()
|
|
||||||
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/", "/protokolle/kea"):
|
|
||||||
assert tclient.get(path).status_code == 404, path
|
|
||||||
body = tclient.get("/").get_data(as_text=True)
|
|
||||||
assert "DHCP-Server" not in body
|
|
||||||
assert "Wartung" not in body
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Geraete
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_device_lifecycle(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/clients/")
|
|
||||||
|
|
||||||
created = tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Kamera Eingang",
|
|
||||||
"mac": "AA-BB-CC-DD-EE-FF",
|
|
||||||
"ip": "192.168.1.50",
|
|
||||||
"is_active": "1",
|
|
||||||
"auto_restart": "1",
|
|
||||||
"ssh_port": "22",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert created.status_code == 200
|
|
||||||
body = created.get_data(as_text=True)
|
|
||||||
assert "angelegt" in body
|
|
||||||
# MAC wird normalisiert.
|
|
||||||
assert "aa:bb:cc:dd:ee:ff" in tclient.get("/clients/").get_data(as_text=True)
|
|
||||||
|
|
||||||
listing = tclient.get("/clients/").get_data(as_text=True)
|
|
||||||
assert "Kamera Eingang" in listing
|
|
||||||
|
|
||||||
# Loeschen legt in den Papierkorb, MAC bleibt reserviert.
|
|
||||||
tclient.post("/clients/1/loeschen", data={"csrf_token": token}, follow_redirects=True)
|
|
||||||
trash = tclient.get("/papierkorb/").get_data(as_text=True)
|
|
||||||
assert "Kamera Eingang" in trash
|
|
||||||
|
|
||||||
again = tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Zweite Kamera",
|
|
||||||
"mac": "aa:bb:cc:dd:ee:ff",
|
|
||||||
"ip": "192.168.1.51",
|
|
||||||
"ssh_port": "22",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "Papierkorb" in again.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_device_requires_port_when_switch_assigned(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/switche/")
|
|
||||||
tclient.post(
|
|
||||||
"/switche/neu",
|
|
||||||
data={"csrf_token": token, "hostname": "sw01", "ip": "192.168.1.2", "ssh_port": "22"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
response = tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Ohne Port",
|
|
||||||
"mac": "11:22:33:44:55:66",
|
|
||||||
"ip": "192.168.1.60",
|
|
||||||
"switch_id": "1",
|
|
||||||
"ssh_port": "22",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "Portbezeichnung" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_mac_rejected(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/clients/")
|
|
||||||
response = tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={"csrf_token": token, "name": "Kaputt", "mac": "xyz", "ip": "192.168.1.70"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "hexadezimale" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_ip_rejected(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/clients/")
|
|
||||||
response = tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={"csrf_token": token, "name": "Kaputt", "mac": "11:22:33:44:55:77", "ip": "999.1.1.1"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "gueltige IP-Adresse" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Zugangsdaten und Verschluesselung
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_credential_secret_is_encrypted_at_rest(tclient, tesm_app, login_admin):
|
|
||||||
from tesm_core.db import Database
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/zugangsdaten/")
|
|
||||||
tclient.post(
|
|
||||||
"/zugangsdaten/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Switch-Admin",
|
|
||||||
"username": "admin",
|
|
||||||
"secret": "SuperGeheim-2026",
|
|
||||||
"category": "switch",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
stored = Database.value(conn, "SELECT secret_encrypted FROM credentials WHERE name=?", ("Switch-Admin",))
|
|
||||||
assert stored
|
|
||||||
assert "SuperGeheim" not in stored
|
|
||||||
assert stored.startswith("v1.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_credential_reveal_requires_permission(tclient, tesm_app, login_admin):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.security import passwords
|
|
||||||
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/zugangsdaten/")
|
|
||||||
tclient.post(
|
|
||||||
"/zugangsdaten/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Nur-Lesen",
|
|
||||||
"username": "admin",
|
|
||||||
"secret": "Geheim-Passwort-9",
|
|
||||||
"category": "switch",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
tclient.get("/logout")
|
|
||||||
|
|
||||||
# Ein Konto mit credentials.view aber ohne credentials.secrets.
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) "
|
|
||||||
"VALUES ('lisa',?,'local',datetime('now'),datetime('now'))",
|
|
||||||
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
||||||
)
|
|
||||||
user_id = int(cursor.lastrowid or 0)
|
|
||||||
group = conn.execute(
|
|
||||||
"INSERT INTO groups (name, created_at, updated_at) "
|
|
||||||
"VALUES ('nur-lesen',datetime('now'),datetime('now'))"
|
|
||||||
)
|
|
||||||
group_id = int(group.lastrowid or 0)
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
|
||||||
[(group_id, "monitoring.view"), (group_id, "credentials.view")],
|
|
||||||
)
|
|
||||||
conn.execute("INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id))
|
|
||||||
|
|
||||||
client = tclient
|
|
||||||
client.post(
|
|
||||||
"/login",
|
|
||||||
data={"username": "lisa", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(client)},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert client.get("/zugangsdaten/").status_code == 200
|
|
||||||
response = client.post("/zugangsdaten/1/anzeigen", data={"csrf_token": _token(client, "/zugangsdaten/")})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_credential_reveal_is_audited(tclient, tesm_app, login_admin):
|
|
||||||
from tesm_core import audit
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/zugangsdaten/")
|
|
||||||
tclient.post(
|
|
||||||
"/zugangsdaten/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Geheim",
|
|
||||||
"username": "admin",
|
|
||||||
"secret": "Geheim-Passwort-9",
|
|
||||||
"category": "switch",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
response = tclient.post(
|
|
||||||
"/zugangsdaten/1/anzeigen", data={"csrf_token": token}, follow_redirects=True
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Geheim-Passwort-9" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn:
|
|
||||||
rows = audit.search(conn, action="credential.secret_revealed")
|
|
||||||
assert rows and rows[0]["severity"] == "warning"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Switche und Host-Schluessel
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_switch_created_without_trusted_host_key(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/switche/")
|
|
||||||
response = tclient.post(
|
|
||||||
"/switche/neu",
|
|
||||||
data={"csrf_token": token, "hostname": "sw-kern", "ip": "10.0.0.2", "ssh_port": "22"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "freigegeben werden" in response.get_data(as_text=True)
|
|
||||||
detail = tclient.get("/switche/1").get_data(as_text=True)
|
|
||||||
assert "Noch kein Schluessel hinterlegt" in detail
|
|
||||||
|
|
||||||
|
|
||||||
def test_switch_delete_blocked_while_devices_attached(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/switche/")
|
|
||||||
tclient.post(
|
|
||||||
"/switche/neu",
|
|
||||||
data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.3", "ssh_port": "22"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
tclient.post(
|
|
||||||
"/clients/neu",
|
|
||||||
data={
|
|
||||||
"csrf_token": token,
|
|
||||||
"name": "Kamera",
|
|
||||||
"mac": "aa:11:22:33:44:55",
|
|
||||||
"ip": "10.0.0.50",
|
|
||||||
"switch_id": "1",
|
|
||||||
"port": "1",
|
|
||||||
"ssh_port": "22",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
response = tclient.post("/switche/1/loeschen", data={"csrf_token": token}, follow_redirects=True)
|
|
||||||
assert "zugeordnet" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_command_not_on_allowlist_rejected(tclient, login_admin):
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/switche/")
|
|
||||||
tclient.post(
|
|
||||||
"/switche/neu",
|
|
||||||
data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.4", "ssh_port": "22"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
response = tclient.post(
|
|
||||||
"/switche/1/kommando",
|
|
||||||
data={"csrf_token": token, "command": "erase startup-config"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert "Positivliste" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Rechte
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_viewer_cannot_reach_admin_pages(tclient, tesm_app):
|
|
||||||
from tesm_core.extension import core
|
|
||||||
from tesm_core.rbac import service as rbac
|
|
||||||
from tesm_core.security import passwords
|
|
||||||
|
|
||||||
extension = core(tesm_app)
|
|
||||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) "
|
|
||||||
"VALUES ('gast',?,'local',datetime('now'),datetime('now'))",
|
|
||||||
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
||||||
)
|
|
||||||
user_id = int(cursor.lastrowid or 0)
|
|
||||||
default = next(group for group in rbac.list_groups(conn) if group.is_default)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, default.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
tclient.post(
|
|
||||||
"/login",
|
|
||||||
data={"username": "gast", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(tclient)},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert tclient.get("/clients/").status_code == 200
|
|
||||||
for path in ("/zugangsdaten/", "/verwaltung/benutzer", "/einstellungen/system", "/diagnose/"):
|
|
||||||
assert tclient.get(path).status_code == 403, path
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_matrix_columns_per_area(tesm_app):
|
|
||||||
from tesm.permissions import PERMISSIONS
|
|
||||||
from tesm_core.rbac.model import Action
|
|
||||||
|
|
||||||
monitoring = PERMISSIONS.area("monitoring")
|
|
||||||
assert Action.SECRETS in monitoring.columns() # Zugangsdaten
|
|
||||||
assert Action.DELETE in monitoring.columns()
|
|
||||||
|
|
||||||
network = PERMISSIONS.area("network")
|
|
||||||
assert Action.CREATE not in network.columns() # DHCP kennt kein "Anlegen"
|
|
||||||
assert Action.SECRETS not in network.columns()
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_is_a_separate_right(tesm_app):
|
|
||||||
"""Kernaenderung gegenueber dem Vorgaenger: Aendern deckt nicht mehr Loeschen ab."""
|
|
||||||
from tesm.permissions import PERMISSIONS
|
|
||||||
|
|
||||||
granted = {"monitoring.view", "devices.view", "devices.edit"}
|
|
||||||
effective = PERMISSIONS.effective(granted)
|
|
||||||
assert "devices.edit" in effective
|
|
||||||
assert "devices.delete" not in effective
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Offline-Aktivierung: der Code muss sichtbar sein
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_offline_request_keeps_its_code():
|
|
||||||
"""Die Seite forderte zum Uebermitteln eines Codes auf -- und zeigte keinen.
|
|
||||||
|
|
||||||
``handshake`` erzeugte ihn, der Blueprint verwarf ihn, und nach der
|
|
||||||
Weiterleitung war er weg. Er gehoert deshalb zur gespeicherten Anfrage:
|
|
||||||
zwischen Anfrage und Antwort liegen in der Praxis eine E-Mail und ein
|
|
||||||
Arbeitstag.
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from tesm_core import licensing_client
|
|
||||||
|
|
||||||
source = inspect.getsource(licensing_client.LicenseManager.handshake)
|
|
||||||
assert '"code": code,' in source
|
|
||||||
assert "code = lic.encode_code(request_payload)" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_reason_for_going_offline_is_named():
|
|
||||||
""""War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch."""
|
|
||||||
from tesm_core.licensing_client import describe_transport_error
|
|
||||||
|
|
||||||
certificate = describe_transport_error(
|
|
||||||
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate"),
|
|
||||||
"https://10.0.0.1:8443",
|
|
||||||
)
|
|
||||||
assert "selbstsigniertes Zertifikat" in certificate
|
|
||||||
assert "https://10.0.0.1:8443" in certificate
|
|
||||||
|
|
||||||
assert "aufloesen" in describe_transport_error(
|
|
||||||
Exception("Name or service not known"), "https://nope.invalid"
|
|
||||||
)
|
|
||||||
assert "nimmt keine Verbindung an" in describe_transport_error(
|
|
||||||
Exception("Connection refused"), "https://10.0.0.1:8443"
|
|
||||||
)
|
|
||||||
assert "rechtzeitig" in describe_transport_error(
|
|
||||||
Exception("operation timed out"), "https://10.0.0.1:8443"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_license_page_shows_the_request_code():
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
template = (
|
|
||||||
Path(__file__).resolve().parent.parent
|
|
||||||
/ "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "license.html"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
assert "pending.code" in template
|
|
||||||
assert "Anfragecode" in template
|
|
||||||
assert 'data-copy="#offline-request"' in template
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Dateifreigaben
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def _license_all_modules(tesm_app):
|
|
||||||
"""Alle Module freischalten -- die Freigabeseiten haengen an der Lizenz."""
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
manager = core(tesm_app).license
|
|
||||||
if manager is not None:
|
|
||||||
manager.module_active = lambda _name: True # type: ignore[method-assign]
|
|
||||||
|
|
||||||
|
|
||||||
def test_share_setup_page_is_reachable_and_searches_groups(tesm_app, tclient, login_admin):
|
|
||||||
"""Die Einrichtung war nur ueber die Adresszeile erreichbar.
|
|
||||||
|
|
||||||
Der Menuepunkt haengte an vorhandenen Einbindungen -- die es ohne
|
|
||||||
Zuordnung nie gibt. Wer Zuordnungen pflegen darf, muss die Seite auch
|
|
||||||
finden, und der Gruppen-DN muss suchbar sein statt abtippbar.
|
|
||||||
"""
|
|
||||||
_license_all_modules(tesm_app)
|
|
||||||
login_admin()
|
|
||||||
|
|
||||||
page = tclient.get("/dateifreigaben/zuordnungen")
|
|
||||||
assert page.status_code == 200
|
|
||||||
body = page.get_data(as_text=True)
|
|
||||||
assert "data-group-query" in body, "Gruppensuche fehlt"
|
|
||||||
assert 'name="ad_group_dn"' in body
|
|
||||||
|
|
||||||
nav = tclient.get("/").get_data(as_text=True)
|
|
||||||
assert "/dateifreigaben/zuordnungen" in nav, "Zuordnungen fehlen in der Navigation"
|
|
||||||
|
|
||||||
|
|
||||||
def test_share_mapping_round_trip(tesm_app, tclient, login_admin):
|
|
||||||
_license_all_modules(tesm_app)
|
|
||||||
login_admin()
|
|
||||||
|
|
||||||
token = _token(tclient, "/dateifreigaben/zuordnungen")
|
|
||||||
response = tclient.post(
|
|
||||||
"/dateifreigaben/zuordnungen",
|
|
||||||
data={
|
|
||||||
"label": "Technik",
|
|
||||||
"unc": r"\\fileserver\technik",
|
|
||||||
"ad_group_name": "GG_Technik",
|
|
||||||
"ad_group_dn": "CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Zuordnung gespeichert" in response.get_data(as_text=True)
|
|
||||||
|
|
||||||
from tesm.services import fileshare
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
with core(tesm_app).database.session() as conn:
|
|
||||||
assert len(fileshare.list_mappings(conn)) == 1
|
|
||||||
# Der Anspruch entsteht ueber die Verzeichnisgruppe -- und nur darueber.
|
|
||||||
assert fileshare.shares_for_groups(
|
|
||||||
conn, ["cn=gg_technik,ou=gruppen,dc=firma,dc=local"]
|
|
||||||
)
|
|
||||||
assert not fileshare.shares_for_groups(conn, [])
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_triggers_share_mounting(tesm_app, tclient, login_admin, monkeypatch):
|
|
||||||
"""Der Ausloeser fehlte: Zuordnungen bestanden, eingebunden wurde nie.
|
|
||||||
|
|
||||||
Ohne diesen Haken blieb der gesamte Freigabe-Teil wirkungslos -- und weil
|
|
||||||
der Menuepunkt an Einbindungen haengt, war er auch unsichtbar.
|
|
||||||
"""
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
seen: list[dict] = []
|
|
||||||
|
|
||||||
def fake_mount(conn, **kwargs):
|
|
||||||
seen.append(kwargs)
|
|
||||||
return []
|
|
||||||
|
|
||||||
_license_all_modules(tesm_app)
|
|
||||||
monkeypatch.setattr("tesm.services.fileshare.mount_for_login", fake_mount)
|
|
||||||
assert core(tesm_app).login_hooks, "kein Anmeldehaken registriert"
|
|
||||||
|
|
||||||
login_admin()
|
|
||||||
assert seen, "die Anmeldung hat die Einbindung nicht ausgeloest"
|
|
||||||
# Das Passwort wird durchgereicht, aber nirgends abgelegt.
|
|
||||||
assert seen[0]["password"]
|
|
||||||
with tclient.session_transaction() as session:
|
|
||||||
assert not any("passw" in str(key).lower() for key in session)
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_releases_shares(tesm_app, tclient, login_admin, monkeypatch):
|
|
||||||
"""Was die Anmeldung eingebunden hat, muss die Abmeldung wieder loesen.
|
|
||||||
|
|
||||||
Sonst bliebe der Zugriff mit dem Konto des Benutzers offen, bis die
|
|
||||||
Zeitgrenze der Einbindung greift -- Stunden spaeter.
|
|
||||||
"""
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
_license_all_modules(tesm_app)
|
|
||||||
released: list[int] = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"tesm.services.fileshare.mount_for_login", lambda conn, **kw: []
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"tesm.services.fileshare.release_all_for_user",
|
|
||||||
lambda conn, user_id: released.append(user_id) or 1,
|
|
||||||
)
|
|
||||||
assert core(tesm_app).logout_hooks, "kein Abmeldehaken registriert"
|
|
||||||
|
|
||||||
login_admin()
|
|
||||||
token = _token(tclient, "/")
|
|
||||||
tclient.post("/logout", data={"csrf_token": token})
|
|
||||||
assert released, "die Abmeldung hat die Freigaben nicht geloest"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"eingabe",
|
|
||||||
[
|
|
||||||
r"\\dc2022\daten",
|
|
||||||
"//dc2022/daten",
|
|
||||||
r"\\dc2022/daten",
|
|
||||||
"//dc2022/daten/",
|
|
||||||
"\\\\dc2022\\daten\\", # Trenner am Ende
|
|
||||||
"//dc2022//daten",
|
|
||||||
" //dc2022/daten ",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_unc_accepts_both_separators(eingabe):
|
|
||||||
"""Backslash und Schraegstrich muessen beide gehen -- und zwar gleich.
|
|
||||||
|
|
||||||
Wer den Pfad aus dem Explorer kopiert, bekommt Backslashes; wer ihn aus
|
|
||||||
einer Weboberflaeche oder von einem Linux-Kollegen bekommt, Schraegstriche.
|
|
||||||
Ueberzaehlige Trenner und einer am Ende gehoeren zum Alltag und duerfen
|
|
||||||
keine Ablehnung ausloesen.
|
|
||||||
"""
|
|
||||||
from tesm.services.fileshare import validate_unc
|
|
||||||
|
|
||||||
assert validate_unc(eingabe) == "\\\\dc2022\\daten"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"eingabe",
|
|
||||||
[
|
|
||||||
"/dc2022/daten", # ein Trenner: absoluter Pfad auf diesem Rechner
|
|
||||||
"//dc2022", # ohne Freigabenamen
|
|
||||||
"dc2022/daten",
|
|
||||||
"//dc 2022/daten",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_unc_rejects_what_is_not_a_share(eingabe):
|
|
||||||
from tesm.services.fileshare import FileshareError, validate_unc
|
|
||||||
|
|
||||||
with pytest.raises(FileshareError):
|
|
||||||
validate_unc(eingabe)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# DHCP: Reservierungen und Optionen je Geraet
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def _netz(cidr: str = "192.168.80.0/24", interface: str = "eth0"):
|
|
||||||
from tesm.services.kea import InterfaceNetwork
|
|
||||||
|
|
||||||
return InterfaceNetwork(interface=interface, cidr=cidr)
|
|
||||||
|
|
||||||
|
|
||||||
def _geraet(conn, name: str, mac: str, ip: str) -> int:
|
|
||||||
cur = conn.execute(
|
|
||||||
"INSERT INTO devices (name, mac, ip, is_active, created_at, updated_at) "
|
|
||||||
"VALUES (?,?,?,1,datetime('now'),datetime('now'))",
|
|
||||||
(name, mac, ip),
|
|
||||||
)
|
|
||||||
return int(cur.lastrowid)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reservations_only_for_existing_ranges(tesm_app):
|
|
||||||
"""Eine Reservierung ausserhalb aller Bereiche liefert Kea nie aus.
|
|
||||||
|
|
||||||
Sie trotzdem zu zeigen, hat Reservierungen versprochen, die es nicht gab --
|
|
||||||
in der Liste standen Adressen, die in der erzeugten Konfiguration fehlten.
|
|
||||||
"""
|
|
||||||
from tesm.services import kea
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
_geraet(conn, "drinnen", "aa:bb:cc:dd:ee:01", "192.168.80.50")
|
|
||||||
_geraet(conn, "draussen", "aa:bb:cc:dd:ee:02", "10.99.0.50")
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
||||||
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
||||||
"datetime('now'),datetime('now'))"
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
netze = kea.configured_networks(conn, [_netz()])
|
|
||||||
assert [str(n) for n in netze] == ["192.168.80.0/24"]
|
|
||||||
|
|
||||||
uebersprungen: list[dict[str, str]] = []
|
|
||||||
eintraege = kea.collect_reservations(
|
|
||||||
conn, only_networks=netze, skipped=uebersprungen
|
|
||||||
)
|
|
||||||
namen = {e["name"] for e in eintraege}
|
|
||||||
assert namen == {"drinnen"}, namen
|
|
||||||
assert [e["ip"] for e in uebersprungen] == ["10.99.0.50"]
|
|
||||||
|
|
||||||
# Ohne Eingrenzung bleibt das alte Verhalten -- der Erzeuger filtert selbst.
|
|
||||||
assert len(kea.collect_reservations(conn)) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_device_option_lands_in_its_reservation(tesm_app):
|
|
||||||
"""Ein Wert je Geraet gehoert in dessen Reservierung, nicht in den globalen Teil."""
|
|
||||||
from tesm.services import kea
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137")
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
||||||
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
||||||
"datetime('now'),datetime('now'))"
|
|
||||||
)
|
|
||||||
option_id = conn.execute(
|
|
||||||
"INSERT INTO dhcp_options (code, name, type, is_standard) VALUES (225,'url','string',0)"
|
|
||||||
).lastrowid
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)",
|
|
||||||
(option_id, device_id, "http://autodarts.local"),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?)",
|
|
||||||
(option_id, "http://global.local"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
config, _ = kea.render_config(conn, networks=[_netz()], settings={})
|
|
||||||
|
|
||||||
subnet = config["Dhcp4"]["subnet4"][0]
|
|
||||||
reservierung = subnet["reservations"][0]
|
|
||||||
assert reservierung["ip-address"] == "192.168.80.137"
|
|
||||||
assert reservierung["option-data"] == [{"name": "url", "data": "http://autodarts.local"}]
|
|
||||||
# Der globale Wert bleibt global und wird nicht verdraengt.
|
|
||||||
assert {"name": "url", "data": "http://global.local"} in config["Dhcp4"]["option-data"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reservation_state_from_leases():
|
|
||||||
"""Wie im Windows-DHCP: eine Reservierung ist aktiv, wenn sie benutzt wird.
|
|
||||||
|
|
||||||
Ohne diese Angabe sah eine nie abgeholte Reservierung genauso aus wie eine
|
|
||||||
benutzte -- und ein Geraet, das noch auf einer alten Adresse haengt und die
|
|
||||||
Reservierung deshalb gar nicht uebernimmt, fiel ueberhaupt nicht auf.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
from tesm.services.kea import Lease, annotate_reservations
|
|
||||||
|
|
||||||
jetzt = int(time.time())
|
|
||||||
benutzt = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 3600, "0")
|
|
||||||
veraltet = Lease("192.168.80.99", "aa:bb:cc:00:00:03", "c", jetzt + 3600, "0")
|
|
||||||
abgelaufen = Lease("192.168.80.12", "aa:bb:cc:00:00:04", "d", jetzt - 60, "0")
|
|
||||||
|
|
||||||
eintraege = annotate_reservations(
|
|
||||||
[
|
|
||||||
{"mac": "aa:bb:cc:00:00:01", "ip": "192.168.80.10"},
|
|
||||||
{"mac": "aa:bb:cc:00:00:02", "ip": "192.168.80.11"},
|
|
||||||
{"mac": "aa:bb:cc:00:00:03", "ip": "192.168.80.30"},
|
|
||||||
{"mac": "aa:bb:cc:00:00:04", "ip": "192.168.80.12"},
|
|
||||||
],
|
|
||||||
[benutzt, veraltet, abgelaufen],
|
|
||||||
)
|
|
||||||
assert [e["state"] for e in eintraege] == [
|
|
||||||
"aktiv",
|
|
||||||
"unbenutzt",
|
|
||||||
"andere_adresse",
|
|
||||||
"abgelaufen",
|
|
||||||
]
|
|
||||||
# Der Hinweis erklaert den Zustand, statt ihn nur zu benennen.
|
|
||||||
assert all(e["state_hint"] for e in eintraege)
|
|
||||||
assert eintraege[2]["lease"].address == "192.168.80.99"
|
|
||||||
|
|
||||||
|
|
||||||
def test_lease_expiry_is_readable():
|
|
||||||
"""Der rohe Unix-Zeitstempel stand vorher unveraendert in der Tabelle."""
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from tesm.services.kea import Lease
|
|
||||||
|
|
||||||
jetzt = int(time.time())
|
|
||||||
lease = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 5400, "0")
|
|
||||||
erwartet = datetime.fromtimestamp(jetzt + 5400).strftime("%d.%m.%Y %H:%M")
|
|
||||||
assert lease.expires_text == erwartet
|
|
||||||
assert lease.remaining_text.startswith("in 1 h")
|
|
||||||
assert str(jetzt) not in lease.expires_text
|
|
||||||
|
|
||||||
assert Lease("x", "aa:bb:cc:00:00:01", "a", 0, "0").expires_text == "unbegrenzt"
|
|
||||||
assert Lease("x", "aa:bb:cc:00:00:01", "a", jetzt - 5, "0").remaining_text == "abgelaufen"
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_option_used_only_in_reservation_is_declared(tesm_app):
|
|
||||||
"""Eine eigene Option muss deklariert sein, auch ohne globalen Wert.
|
|
||||||
|
|
||||||
Genau das hat den Dienst umgebracht: der Wert stand nur in einer
|
|
||||||
Reservierung, die ``option-def`` wurde aber nur fuer Optionen mit globalem
|
|
||||||
Wert erzeugt. Kea startet dann nicht mehr --
|
|
||||||
"definition for the option 'dhcp4.url' does not exist" -- und weil das erst
|
|
||||||
beim naechsten Start auffiel, lag der DHCP-Server stundenlang.
|
|
||||||
"""
|
|
||||||
from tesm.services import kea
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137")
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
||||||
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
||||||
"datetime('now'),datetime('now'))"
|
|
||||||
)
|
|
||||||
option_id = conn.execute(
|
|
||||||
"INSERT INTO dhcp_options (code, name, type, is_standard) "
|
|
||||||
"VALUES (225,'url','string',0)"
|
|
||||||
).lastrowid
|
|
||||||
# Bewusst *kein* globaler Wert -- nur der Wert fuer dieses eine Geraet.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)",
|
|
||||||
(option_id, device_id, "https://play.autodarts.com"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
config, _ = kea.render_config(conn, networks=[_netz()], settings={})
|
|
||||||
|
|
||||||
dhcp4 = config["Dhcp4"]
|
|
||||||
benutzt = dhcp4["subnet4"][0]["reservations"][0]["option-data"]
|
|
||||||
assert benutzt == [{"name": "url", "data": "https://play.autodarts.com"}]
|
|
||||||
assert "option-data" not in dhcp4, "kein globaler Wert erwartet"
|
|
||||||
|
|
||||||
definitionen = {entry["name"]: entry for entry in dhcp4["option-def"]}
|
|
||||||
assert "url" in definitionen, "ohne option-def verweigert Kea den Start"
|
|
||||||
assert definitionen["url"] == {
|
|
||||||
"name": "url",
|
|
||||||
"code": 225,
|
|
||||||
"type": "string",
|
|
||||||
"space": "dhcp4",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_marks_device_as_restarting(tesm_app):
|
|
||||||
"""Nach einem Neustart muss die Kachel das sofort zeigen.
|
|
||||||
|
|
||||||
Der Zustand wechselte vorher erst beim naechsten Pruefdurchlauf -- Vorgabe
|
|
||||||
fuenf Minuten. Ein Neustart ueber SSH ist in vierzig Sekunden durch: das
|
|
||||||
Geraet war weg und wieder da, ohne dass es je jemand gesehen haette. Die
|
|
||||||
Kachel blieb die ganze Zeit auf "online".
|
|
||||||
"""
|
|
||||||
from tesm.services import monitor
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
device_id = _geraet(conn, "kasse", "aa:bb:cc:00:00:07", "192.168.80.7")
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO device_status (device_id, state, last_change_at) "
|
|
||||||
"VALUES (?, 'online', datetime('now'))",
|
|
||||||
(device_id,),
|
|
||||||
)
|
|
||||||
monitor.record_restart(
|
|
||||||
conn,
|
|
||||||
device_id=device_id,
|
|
||||||
action="reboot",
|
|
||||||
result="ok",
|
|
||||||
trigger="manuell",
|
|
||||||
actor="test",
|
|
||||||
duration_ms=100,
|
|
||||||
detail="",
|
|
||||||
method=monitor.METHOD_SSH,
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
zeile = conn.execute(
|
|
||||||
"SELECT state, restart_count FROM device_status WHERE device_id=?", (device_id,)
|
|
||||||
).fetchone()
|
|
||||||
assert zeile["state"] == monitor.STATE_RESTARTING
|
|
||||||
assert zeile["restart_count"] == 1
|
|
||||||
assert monitor.dashboard_counts(conn)["restarting"] == 1
|
|
||||||
|
|
||||||
# Solange es schweigt, bleibt es "startet neu" -- nicht "ausgefallen".
|
|
||||||
vorher, nachher = monitor.record_status(
|
|
||||||
conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="")
|
|
||||||
)
|
|
||||||
assert nachher == monitor.STATE_RESTARTING, "ein laufender Neustart ist kein Ausfall"
|
|
||||||
|
|
||||||
# Antwortet es wieder, ist es online.
|
|
||||||
_, nachher = monitor.record_status(
|
|
||||||
conn, device_id, monitor.PingResult(ok=True, latency_ms=3, detail="")
|
|
||||||
)
|
|
||||||
assert nachher == "online"
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_state_gives_up_after_the_grace_period(tesm_app):
|
|
||||||
"""Ein Neustart, der fuenf Minuten dauert, ist keiner mehr."""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from tesm.services import monitor
|
|
||||||
from tesm_core.extension import core
|
|
||||||
|
|
||||||
ext = core(tesm_app)
|
|
||||||
lange_her = (
|
|
||||||
datetime.now(timezone.utc) - timedelta(seconds=monitor.RESTART_GRACE_SECONDS + 60)
|
|
||||||
).isoformat()
|
|
||||||
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
||||||
device_id = _geraet(conn, "haenger", "aa:bb:cc:00:00:08", "192.168.80.8")
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO device_status (device_id, state, last_restart_at, last_change_at) "
|
|
||||||
"VALUES (?, ?, ?, datetime('now'))",
|
|
||||||
(device_id, monitor.STATE_RESTARTING, lange_her),
|
|
||||||
)
|
|
||||||
|
|
||||||
with ext.database.session() as conn:
|
|
||||||
_, nachher = monitor.record_status(
|
|
||||||
conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="")
|
|
||||||
)
|
|
||||||
assert nachher == "offline"
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
"""Die nginx-Vorlage und die Trennung mehrerer Instanzen auf einem Host.
|
|
||||||
|
|
||||||
Zwei Fehlerklassen werden hier festgenagelt:
|
|
||||||
|
|
||||||
1. Der alias-Fehler des Vorgaengers -- statische Dateien zeigten auf ein
|
|
||||||
Verzeichnis, das es nicht gab, und die Oberflaeche kam jahrelang ohne
|
|
||||||
Design. Die erzeugte Datei muss die richtigen Pfade enthalten.
|
|
||||||
2. Zwei Installationen derselben Anwendung auf einem Host. Solange alles
|
|
||||||
ueber den ``app_key`` benannt wurde, hat eine Testinstanz beim Speichern
|
|
||||||
der Webserver-Einstellungen die Site-Datei der Produktion ueberschrieben
|
|
||||||
-- und beide teilten sich dasselbe Sitzungscookie, weil Cookies keine
|
|
||||||
Ports unterscheiden.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from tesm_core.system import webserver
|
|
||||||
from tesm_core.sysops import HELPER_VERBS, Result, SysOps
|
|
||||||
|
|
||||||
|
|
||||||
def make_config(**overrides) -> webserver.WebConfig:
|
|
||||||
base = {
|
|
||||||
"app_key": "tesm",
|
|
||||||
"upstream": "127.0.0.1:5000",
|
|
||||||
"static_root": "/srv/tesm/static",
|
|
||||||
}
|
|
||||||
return webserver.WebConfig(**{**base, **overrides})
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Nur HTTP
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_only_has_no_tls_block():
|
|
||||||
text = webserver.render(make_config())
|
|
||||||
assert "listen 80 default_server;" in text
|
|
||||||
assert "ssl_certificate" not in text
|
|
||||||
assert "return 308" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_static_aliases_point_into_the_installation():
|
|
||||||
text = webserver.render(make_config())
|
|
||||||
assert "alias /srv/tesm/static/;" in text
|
|
||||||
assert "alias /srv/tesm/static-core/;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_passes_the_host_including_port():
|
|
||||||
"""``$host`` verwirft den Port -- damit scheitert die Origin-Pruefung.
|
|
||||||
|
|
||||||
Genau daran ist die Anmeldung hinter dem Reverse Proxy auf einem
|
|
||||||
abweichenden Port real gescheitert.
|
|
||||||
"""
|
|
||||||
text = webserver.render(make_config())
|
|
||||||
assert "proxy_set_header Host $http_host;" in text
|
|
||||||
assert "proxy_set_header Host $host;" not in text
|
|
||||||
assert "proxy_set_header X-Forwarded-Port $server_port;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_acme_path_is_always_reachable():
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
|
||||||
)
|
|
||||||
# Auch bei aktiver Umleitung -- sonst kann Let's Encrypt nie erneuern.
|
|
||||||
assert "/.well-known/acme-challenge/" in text
|
|
||||||
challenge = text.index("acme-challenge")
|
|
||||||
redirect = text.index("return 308")
|
|
||||||
assert challenge < redirect
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# HTTPS
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_https_listens_on_443_and_redirects_80():
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
|
||||||
)
|
|
||||||
assert "listen 443 ssl default_server http2;" in text
|
|
||||||
assert "listen [::]:443 ssl default_server http2;" in text
|
|
||||||
assert "return 308 https://$host$request_uri;" in text
|
|
||||||
assert "ssl_protocols TLSv1.2 TLSv1.3;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_hsts_only_when_asked():
|
|
||||||
without = webserver.render(
|
|
||||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
|
||||||
)
|
|
||||||
assert "Strict-Transport-Security" not in without
|
|
||||||
|
|
||||||
with_hsts = webserver.render(
|
|
||||||
make_config(
|
|
||||||
https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert "Strict-Transport-Security" in with_hsts
|
|
||||||
# HSTS ohne TLS waere ein Selbstschuss: der Browser merkt sich, dass er die
|
|
||||||
# Seite nur noch ueber HTTPS aufrufen darf.
|
|
||||||
assert "ssl_certificate" in with_hsts
|
|
||||||
|
|
||||||
|
|
||||||
def test_hsts_is_ignored_without_https():
|
|
||||||
text = webserver.render(make_config(hsts_enabled=True))
|
|
||||||
assert "Strict-Transport-Security" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_can_be_switched_off():
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(
|
|
||||||
https_enabled=True, redirect_http=False, cert_path="/c.pem", key_path="/k.pem"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert "return 308" not in text
|
|
||||||
# Dann muss Port 80 die Anwendung weiter bedienen.
|
|
||||||
assert text.count("proxy_pass http://127.0.0.1:5000;") >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_deviating_ports_are_rendered_and_redirect_keeps_the_port():
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(
|
|
||||||
https_enabled=True,
|
|
||||||
cert_path="/c.pem",
|
|
||||||
key_path="/k.pem",
|
|
||||||
http_port=8080,
|
|
||||||
https_port=8443,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert "listen 8080 default_server;" in text
|
|
||||||
assert "listen 8443 ssl default_server http2;" in text
|
|
||||||
# Ohne den Port im Ziel liefe die Umleitung ins Leere.
|
|
||||||
assert "return 308 https://$host:8443$request_uri;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_standard_port_leaves_no_suffix():
|
|
||||||
assert make_config(https_port=443).https_suffix == ""
|
|
||||||
assert make_config(https_port=8443).https_suffix == ":8443"
|
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_location_only_when_configured():
|
|
||||||
assert "proxy_set_header Upgrade" not in webserver.render(make_config())
|
|
||||||
text = webserver.render(make_config(websocket_path="/ws/"))
|
|
||||||
assert "location /ws/ {" in text
|
|
||||||
assert "proxy_read_timeout 3600s;" in text
|
|
||||||
|
|
||||||
|
|
||||||
def _location_blocks(text: str) -> list[list[str]]:
|
|
||||||
"""Zerlegt die erzeugte Datei in ihre ``location``-Bloecke."""
|
|
||||||
blocks: list[list[str]] = []
|
|
||||||
current: list[str] | None = None
|
|
||||||
for line in text.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.startswith("location ") and stripped.endswith("{"):
|
|
||||||
current = []
|
|
||||||
elif stripped == "}" and current is not None:
|
|
||||||
blocks.append(current)
|
|
||||||
current = None
|
|
||||||
elif current is not None and stripped and not stripped.startswith("#"):
|
|
||||||
current.append(stripped)
|
|
||||||
return blocks
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"config",
|
|
||||||
[
|
|
||||||
make_config(websocket_path="/ws/"),
|
|
||||||
make_config(
|
|
||||||
websocket_path="/ws/", https_enabled=True, cert_path="/c.pem", key_path="/k.pem"
|
|
||||||
),
|
|
||||||
make_config(https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_no_directive_appears_twice_in_one_block(config: webserver.WebConfig):
|
|
||||||
"""nginx weist eine doppelte Direktive im selben Block ab.
|
|
||||||
|
|
||||||
Auf POETEST real passiert: der Websocket-Block erbte ``proxy_read_timeout
|
|
||||||
120s`` aus dem gemeinsamen Teil und setzte danach ``3600s``. nginx meldete
|
|
||||||
"directive is duplicate" und liess sich nicht mehr starten -- nicht nur
|
|
||||||
fuer diese Site, sondern fuer alle.
|
|
||||||
"""
|
|
||||||
for block in _location_blocks(webserver.render(config)):
|
|
||||||
directives = [line.split()[0] for line in block if line.endswith(";")]
|
|
||||||
repeated = {
|
|
||||||
name
|
|
||||||
for name in directives
|
|
||||||
# Wiederholung ist nur bei den Direktiven zulaessig, die nginx als
|
|
||||||
# Liste versteht.
|
|
||||||
if directives.count(name) > 1 and name not in {"proxy_set_header", "add_header"}
|
|
||||||
}
|
|
||||||
assert not repeated, f"Mehrfach im selben Block: {sorted(repeated)}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_block_keeps_the_long_timeout():
|
|
||||||
"""Die lange Wartezeit ist der Zweck des Blocks -- sie darf nicht verlorengehen."""
|
|
||||||
blocks = {
|
|
||||||
block[0]: block
|
|
||||||
for block in _location_blocks(webserver.render(make_config(websocket_path="/ws/")))
|
|
||||||
}
|
|
||||||
websocket = next(b for b in blocks.values() if "proxy_set_header Upgrade $http_upgrade;" in b)
|
|
||||||
assert "proxy_read_timeout 3600s;" in websocket
|
|
||||||
assert "proxy_read_timeout 120s;" not in websocket
|
|
||||||
|
|
||||||
|
|
||||||
def test_http2_uses_the_old_spelling_by_default():
|
|
||||||
"""``http2 on;`` kennt erst nginx 1.25.1.
|
|
||||||
|
|
||||||
Auf POETEST (nginx 1.24) fuehrte die neue Direktive zu "unknown directive"
|
|
||||||
-- und damit zu einem Webserver, der sich nicht mehr starten liess. Die
|
|
||||||
alte Schreibweise laeuft ueberall.
|
|
||||||
"""
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
|
||||||
)
|
|
||||||
assert "listen 443 ssl default_server http2;" in text
|
|
||||||
assert "http2 on;" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_http2_directive_on_request():
|
|
||||||
text = webserver.render(
|
|
||||||
make_config(
|
|
||||||
https_enabled=True, cert_path="/c.pem", key_path="/k.pem", http2_directive=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert " http2 on;" in text
|
|
||||||
assert "listen 443 ssl default_server;" in text
|
|
||||||
assert "ssl http2;" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_stapling_only_with_a_real_certificate():
|
|
||||||
"""Ein selbstsigniertes Zertifikat hat keinen OCSP-Responder."""
|
|
||||||
self_signed = webserver.render(
|
|
||||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
|
||||||
)
|
|
||||||
assert "ssl_stapling" not in self_signed
|
|
||||||
|
|
||||||
public = webserver.render(
|
|
||||||
make_config(
|
|
||||||
https_enabled=True,
|
|
||||||
domain="tesm.example.org",
|
|
||||||
cert_path="/c.pem",
|
|
||||||
key_path="/k.pem",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert "ssl_stapling on;" in public
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Domain
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"value", ["tesm.example.org", "a.b.c.de", "TESM.Example.ORG", "tesm.example.org."]
|
|
||||||
)
|
|
||||||
def test_valid_domains(value: str):
|
|
||||||
assert webserver.validate_domain(value) == value.lower().rstrip(".")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"value", ["kein-punkt", "-start.de", "ende-.de", "raum .de", "a..b", "http://x.de"]
|
|
||||||
)
|
|
||||||
def test_invalid_domains(value: str):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
webserver.validate_domain(value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_domain_becomes_catch_all():
|
|
||||||
assert make_config().server_name == "_"
|
|
||||||
assert make_config(domain="tesm.example.org").server_name == "tesm.example.org"
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_nameless_site_claims_default_server():
|
|
||||||
"""Sonst entscheidet der Dateiname in sites-enabled, wer die Vorgabe wird.
|
|
||||||
|
|
||||||
``server_name _;`` ist bei nginx kein Sonderfall -- ohne
|
|
||||||
``default_server`` gewinnt schlicht die zuerst geladene Datei. Bei zwei
|
|
||||||
Anwendungen auf einem Host waere das reiner Zufall.
|
|
||||||
"""
|
|
||||||
nameless = webserver.render(make_config())
|
|
||||||
assert "listen 80 default_server;" in nameless
|
|
||||||
|
|
||||||
named = webserver.render(make_config(domain="lizenz.example.org"))
|
|
||||||
assert "default_server" not in named
|
|
||||||
assert "server_name lizenz.example.org;" in named
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Selbstsigniertes Zertifikat
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSysOps:
|
|
||||||
"""Merkt sich den Aufruf, statt den Helfer wirklich zu starten."""
|
|
||||||
|
|
||||||
def __init__(self, result: Result) -> None:
|
|
||||||
self.result = result
|
|
||||||
self.calls: list[tuple[str, ...]] = []
|
|
||||||
self.available = True
|
|
||||||
self.site_key = "tesm-opus"
|
|
||||||
|
|
||||||
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
|
|
||||||
self.calls.append((common_name, *alt_names))
|
|
||||||
return self.result
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_returns_both_paths():
|
|
||||||
sysops = FakeSysOps(Result(True, "/etc/tesm/certs/tesm/fullchain.pem\n/etc/tesm/certs/tesm/privkey.pem\n"))
|
|
||||||
result = webserver.create_self_signed(
|
|
||||||
sysops, common_name="poetest", alt_names=["poetest.local", "192.168.82.202"]
|
|
||||||
)
|
|
||||||
assert result["ok"]
|
|
||||||
assert result["cert_path"].endswith("fullchain.pem")
|
|
||||||
assert result["key_path"].endswith("privkey.pem")
|
|
||||||
assert sysops.calls == [("poetest", "poetest.local", "192.168.82.202")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_rejects_a_hostile_name():
|
|
||||||
"""Der Name landet in einem openssl-Argument -- er wird hier geprueft."""
|
|
||||||
sysops = FakeSysOps(Result(True, "a\nb\n"))
|
|
||||||
for hostile in ("a b", "a;rm -rf /", "$(whoami)", "../../etc", ""):
|
|
||||||
result = webserver.create_self_signed(sysops, common_name=hostile)
|
|
||||||
assert not result["ok"], hostile
|
|
||||||
assert sysops.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_drops_unusable_alternative_names():
|
|
||||||
sysops = FakeSysOps(Result(True, "a\nb\n"))
|
|
||||||
webserver.create_self_signed(
|
|
||||||
sysops, common_name="poetest", alt_names=["poetest", "", "bad name", "ok.local"]
|
|
||||||
)
|
|
||||||
assert sysops.calls == [("poetest", "ok.local")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_reports_a_failing_helper():
|
|
||||||
sysops = FakeSysOps(Result(False, "", "openssl fehlt", 2))
|
|
||||||
result = webserver.create_self_signed(sysops, common_name="poetest")
|
|
||||||
assert not result["ok"]
|
|
||||||
assert "openssl fehlt" in result["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_rejects_an_unexpected_answer():
|
|
||||||
sysops = FakeSysOps(Result(True, "nur eine Zeile\n"))
|
|
||||||
result = webserver.create_self_signed(sysops, common_name="poetest")
|
|
||||||
assert not result["ok"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_info_survives_an_unreadable_file(tmp_path, monkeypatch):
|
|
||||||
"""Ein unlesbares Zertifikat darf die Einstellungsseite nicht abwerfen.
|
|
||||||
|
|
||||||
Auf POETEST real passiert: das Zertifikatsverzeichnis war 0750 root:root,
|
|
||||||
der Dienstbenutzer bekam beim blossen ``stat`` einen PermissionError -- und
|
|
||||||
die Seite "Webserver und TLS" antwortete mit HTTP 500.
|
|
||||||
"""
|
|
||||||
target = tmp_path / "fullchain.pem"
|
|
||||||
target.write_text("egal", encoding="utf-8")
|
|
||||||
|
|
||||||
def deny(*_args, **_kwargs):
|
|
||||||
raise PermissionError(13, "Permission denied")
|
|
||||||
|
|
||||||
monkeypatch.setattr("pathlib.Path.is_file", deny)
|
|
||||||
info = webserver.certificate_info(str(target))
|
|
||||||
assert info["present"] is True
|
|
||||||
assert "nicht lesen" in info["error"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_info_reports_a_missing_file():
|
|
||||||
assert webserver.certificate_info("/gibt/es/nicht.pem") == {"present": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_info_reports_garbage_without_raising(tmp_path):
|
|
||||||
target = tmp_path / "fullchain.pem"
|
|
||||||
target.write_text("kein Zertifikat", encoding="utf-8")
|
|
||||||
info = webserver.certificate_info(str(target))
|
|
||||||
assert info["present"] is True
|
|
||||||
assert info["error"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_names_are_usable_in_a_certificate():
|
|
||||||
names = webserver.local_names()
|
|
||||||
assert names, "Es sollte mindestens der Hostname gefunden werden."
|
|
||||||
for name in names:
|
|
||||||
assert re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name), name
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Instanztrennung
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_config_is_one_call_with_rollback():
|
|
||||||
"""Getrennte Aufrufe liessen eine abgelehnte Konfiguration aktiviert liegen.
|
|
||||||
|
|
||||||
``nginx-write`` verlinkt sofort nach ``sites-enabled``. Schlug danach
|
|
||||||
``nginx -t`` fehl, blieb die kaputte Datei aktiv -- der laufende nginx
|
|
||||||
merkte nichts, aber der naechste Reload durch irgendwen legte den
|
|
||||||
Webserver lahm. Die Uebernahme muss deshalb *eine* privilegierte Operation
|
|
||||||
sein, die sich selbst zuruecknimmt.
|
|
||||||
"""
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
class Recording(SysOps):
|
|
||||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
|
||||||
calls.append(verb)
|
|
||||||
return Result(True, "uebernommen")
|
|
||||||
|
|
||||||
result = webserver.apply_config(Recording(app_key="tesm"), "server {}")
|
|
||||||
assert result["ok"]
|
|
||||||
assert calls == ["nginx-apply"]
|
|
||||||
assert "nginx-write" not in calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_config_reports_a_rejection_without_claiming_success():
|
|
||||||
class Failing(SysOps):
|
|
||||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
|
||||||
return Result(False, "", "unknown directive \"http2\"", 2)
|
|
||||||
|
|
||||||
result = webserver.apply_config(Failing(app_key="tesm"), "server {}")
|
|
||||||
assert not result["ok"]
|
|
||||||
assert "alte Stand" in result["message"]
|
|
||||||
assert "http2" in result["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sysops_writes_system_files_under_the_site_key():
|
|
||||||
"""Sonst ueberschreibt eine Testinstanz die Systemdateien der Produktion."""
|
|
||||||
calls: list[tuple[str, ...]] = []
|
|
||||||
|
|
||||||
class Recording(SysOps):
|
|
||||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
|
||||||
calls.append((verb, *args))
|
|
||||||
return Result(True)
|
|
||||||
|
|
||||||
sysops = Recording(app_key="tesm", site_key="tesm-opus")
|
|
||||||
sysops.write_nginx("x")
|
|
||||||
sysops.write_netplan("x")
|
|
||||||
sysops.write_logrotate("x")
|
|
||||||
sysops.issue_self_signed("poetest")
|
|
||||||
|
|
||||||
assert calls == [
|
|
||||||
("nginx-write", "tesm-opus"),
|
|
||||||
("netplan-write", "tesm-opus"),
|
|
||||||
("logrotate-write", "tesm-opus"),
|
|
||||||
("selfsigned-issue", "tesm-opus", "poetest"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_upstream_follows_the_configured_bind_address(monkeypatch):
|
|
||||||
"""Die nginx-Site muss auf *diese* Instanz zeigen, nicht auf Port 5000.
|
|
||||||
|
|
||||||
Mit fest verdrahteter 5000 erzeugte eine Instanz auf Port 5100 eine
|
|
||||||
Konfiguration, die Anfragen an die *andere* Installation weiterreichte --
|
|
||||||
auf POETEST reproduziert: HTTPS stand, lieferte aber die alte Anwendung.
|
|
||||||
"""
|
|
||||||
from tesm_core.config import load_core_config
|
|
||||||
|
|
||||||
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100")
|
|
||||||
config = load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
|
||||||
assert config.bind == "127.0.0.1:5100"
|
|
||||||
|
|
||||||
text = webserver.render(make_config(upstream=config.bind))
|
|
||||||
assert "proxy_pass http://127.0.0.1:5100;" in text
|
|
||||||
assert "127.0.0.1:5000" not in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_bind_address_is_validated(monkeypatch):
|
|
||||||
from tesm_core.config import load_core_config
|
|
||||||
|
|
||||||
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100; rm -rf /")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
|
||||||
|
|
||||||
|
|
||||||
def test_site_key_is_validated(monkeypatch):
|
|
||||||
from tesm_core.config import load_core_config
|
|
||||||
|
|
||||||
monkeypatch.setenv("TESM_SITE_KEY", "../../etc/nginx")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
|
||||||
|
|
||||||
|
|
||||||
def test_site_key_defaults_to_the_app_key():
|
|
||||||
assert SysOps(app_key="tesm").site_key == "tesm"
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_signed_verb_is_known():
|
|
||||||
assert "selfsigned-issue" in HELPER_VERBS
|
|
||||||
|
|
||||||
|
|
||||||
def test_service_state_finds_unit_without_suffix(monkeypatch):
|
|
||||||
"""``list-unit-files`` braucht ein Muster mit Endung.
|
|
||||||
|
|
||||||
Ohne sie fand die Abfrage nichts und der Zustand lautete
|
|
||||||
"installiert: nein" -- auch bei laufendem Dienst. Auf der DHCP-Seite lud
|
|
||||||
das zum Klick auf "Kea installieren" ein.
|
|
||||||
"""
|
|
||||||
import tesm_core.sysops as sysops_modul
|
|
||||||
from tesm_core.sysops import Result, SysOps
|
|
||||||
|
|
||||||
# Ohne systemctl bricht die Abfrage vorher ab -- auf dem Entwicklungsrechner
|
|
||||||
# gibt es keines.
|
|
||||||
monkeypatch.setattr(sysops_modul.shutil, "which", lambda name: "/bin/" + name)
|
|
||||||
|
|
||||||
gesehen: list[list[str]] = []
|
|
||||||
|
|
||||||
class Attrappe(SysOps):
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def read_command(self, argv):
|
|
||||||
gesehen.append(list(argv))
|
|
||||||
if "list-unit-files" in argv:
|
|
||||||
treffer = "kea-dhcp4-server.service" in argv
|
|
||||||
return Result(True, "kea-dhcp4-server.service enabled" if treffer else "", "")
|
|
||||||
return Result(True, "active", "")
|
|
||||||
|
|
||||||
zustand = Attrappe().service_state("kea-dhcp4-server")
|
|
||||||
assert zustand["installed"] == "ja", gesehen
|
|
||||||
# Eine bereits vollstaendige Unit bleibt unveraendert.
|
|
||||||
assert Attrappe().service_state("kea-dhcp4-server.service")["installed"] == "ja"
|
|
||||||
Reference in New Issue
Block a user