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
|
||||
ein SSH-Terminal im Browser.
|
||||
|
||||
Der Lizenzserver liegt in einem eigenen Repository:
|
||||
[alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license).
|
||||
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.
|
||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von
|
||||
TESM gebraucht wird** -- nichts darueber hinaus:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
@@ -20,7 +35,7 @@ das Repository allein installieren laesst.
|
||||
|
||||
---
|
||||
|
||||
## Installation auf einem Server
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
@@ -64,41 +79,7 @@ Rollback.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## Betrieb von der Kommandozeile
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```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
|
||||
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
|
||||
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten, Rechte,
|
||||
Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
||||
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten,
|
||||
Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
|
||||
|
||||
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase`
|
||||
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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [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/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
|
||||
|
||||
---
|
||||
|
||||
## 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'
|
||||
```
|
||||
Aufbau und Entwurfsentscheidungen stehen in `docs/ARCHITEKTUR.md` im
|
||||
Entwicklungsrepository, ebenso die Tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,10 +145,10 @@ anders geloest ist. Die wichtigsten:
|
||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
||||
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
|
||||
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.
|
||||
* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer
|
||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||
* **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
|
||||
# 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 \
|
||||
"$ROOT/packages/tesm-licensing" \
|
||||
"$ROOT/packages/tesm-core" \
|
||||
"$ROOT/apps/$APP" \
|
||||
gunicorn
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
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
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
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,
|
||||
decode_code,
|
||||
encode_code,
|
||||
generate_keypair,
|
||||
public_from_private,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
@@ -44,12 +42,10 @@ from .license import (
|
||||
ROLE_LABELS,
|
||||
TRIAL_DEFAULT_DAYS,
|
||||
LicenseStatus,
|
||||
issue_license,
|
||||
iso,
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
resign_license,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
@@ -62,9 +58,7 @@ from .protocol import (
|
||||
REQUEST_MAX_AGE_SECONDS,
|
||||
RESPONSE_STATES,
|
||||
build_client_request,
|
||||
build_master_response,
|
||||
new_nonce,
|
||||
verify_client_request,
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
@@ -100,26 +94,20 @@ __all__ = [
|
||||
"TRIAL_DEFAULT_DAYS",
|
||||
"UntrustedIssuer",
|
||||
"build_client_request",
|
||||
"build_master_response",
|
||||
"canonical_bytes",
|
||||
"decode_code",
|
||||
"encode_code",
|
||||
"generate_keypair",
|
||||
"issue_license",
|
||||
"iso",
|
||||
"key_id",
|
||||
"license_status",
|
||||
"machine_identity",
|
||||
"new_nonce",
|
||||
"parse_iso",
|
||||
"public_from_private",
|
||||
"resign_license",
|
||||
"sign",
|
||||
"split_bundle",
|
||||
"system_fingerprint",
|
||||
"utcnow",
|
||||
"verify",
|
||||
"verify_client_request",
|
||||
"verify_license",
|
||||
"verify_master_response",
|
||||
]
|
||||
|
||||
@@ -45,21 +45,8 @@ def _b64d(value: str, *, field: str) -> bytes:
|
||||
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:
|
||||
|
||||
@@ -34,7 +34,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
|
||||
FORMAT_VERSION = 2
|
||||
@@ -147,110 +147,10 @@ class LicenseStatus:
|
||||
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]:
|
||||
|
||||
@@ -95,7 +95,7 @@ def build_client_request(
|
||||
return payload
|
||||
|
||||
|
||||
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
def check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
try:
|
||||
stamp = parse_iso(timestamp)
|
||||
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")
|
||||
|
||||
|
||||
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(
|
||||
@@ -230,7 +149,7 @@ def verify_master_response(
|
||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||
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")
|
||||
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