diff --git a/README.md b/README.md index eca36c0..4a6cc8a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deploy/install.sh b/deploy/install.sh index a27801a..8130693 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md deleted file mode 100644 index cdecece..0000000 --- a/docs/ARCHITEKTUR.md +++ /dev/null @@ -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/`: 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. diff --git a/packages/tesm-licensing/src/tesm_licensing/__init__.py b/packages/tesm-licensing/src/tesm_licensing/__init__.py index 20c2cc6..3e2a5c4 100644 --- a/packages/tesm-licensing/src/tesm_licensing/__init__.py +++ b/packages/tesm-licensing/src/tesm_licensing/__init__.py @@ -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", ] diff --git a/packages/tesm-licensing/src/tesm_licensing/canonical.py b/packages/tesm-licensing/src/tesm_licensing/canonical.py index 19ad3aa..11c50c6 100644 --- a/packages/tesm-licensing/src/tesm_licensing/canonical.py +++ b/packages/tesm-licensing/src/tesm_licensing/canonical.py @@ -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: diff --git a/packages/tesm-licensing/src/tesm_licensing/license.py b/packages/tesm-licensing/src/tesm_licensing/license.py index feec766..c4b24ac 100644 --- a/packages/tesm-licensing/src/tesm_licensing/license.py +++ b/packages/tesm-licensing/src/tesm_licensing/license.py @@ -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]: diff --git a/packages/tesm-licensing/src/tesm_licensing/protocol.py b/packages/tesm-licensing/src/tesm_licensing/protocol.py index f053e45..ee8d8b7 100644 --- a/packages/tesm-licensing/src/tesm_licensing/protocol.py +++ b/packages/tesm-licensing/src/tesm_licensing/protocol.py @@ -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: diff --git a/tesm-v2.0.0.tar.gz b/tesm-v2.0.0.tar.gz new file mode 100644 index 0000000..6f10933 Binary files /dev/null and b/tesm-v2.0.0.tar.gz differ diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e4ec9ec..0000000 --- a/tests/conftest.py +++ /dev/null @@ -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 %}

Start

{% 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 %}

Widgets

{% 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 diff --git a/tests/e2e_smoke.py b/tests/e2e_smoke.py deleted file mode 100644 index 66e3d3c..0000000 --- a/tests/e2e_smoke.py +++ /dev/null @@ -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">(.*?)', re.S) -ROW_SPLIT = re.compile(r"]|]") - - -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])) diff --git a/tests/test_core_security.py b/tests/test_core_security.py deleted file mode 100644 index 1b52c57..0000000 --- a/tests/test_core_security.py +++ /dev/null @@ -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" diff --git a/tests/test_core_smoke.py b/tests/test_core_smoke.py deleted file mode 100644 index 8f093b9..0000000 --- a/tests/test_core_smoke.py +++ /dev/null @@ -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"]*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 diff --git a/tests/test_legacy_transfer.py b/tests/test_legacy_transfer.py deleted file mode 100644 index 866bc3f..0000000 --- a/tests/test_legacy_transfer.py +++ /dev/null @@ -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 diff --git a/tests/test_licensing.py b/tests/test_licensing.py deleted file mode 100644 index a945dc8..0000000 --- a/tests/test_licensing.py +++ /dev/null @@ -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) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py deleted file mode 100644 index 3b48772..0000000 --- a/tests/test_repo_hygiene.py +++ /dev/null @@ -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/``. - """ - 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//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 "und" im Text. - if re.search(r"&[a-zA-Z]{2,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}" diff --git a/tests/test_tesm_restarts_ssh.py b/tests/test_tesm_restarts_ssh.py deleted file mode 100644 index 2540b91..0000000 --- a/tests/test_tesm_restarts_ssh.py +++ /dev/null @@ -1,1376 +0,0 @@ -"""Neustarts und SSH-Zugang zu Clients. - -Zwei Luecken, die erst im Betrieb auffielen: - -1. **Die Neustart-Historie zeigte nur die halbe Wahrheit.** Sie hiess - "PoE-Vorgaenge" und enthielt auch nur PoE-Vorgaenge. Ein Geraet, das die - Wartung per ``reboot`` neu gestartet hatte, fehlte dort vollstaendig -- - obwohl genau dort nachsieht, wer wissen will, warum ein Geraet weg war. - -2. **Fuer Clients gab es keine Host-Schluessel-Freigabe.** Die Wartung - verweigerte den Dienst ("Schluessel unbekannt"), und es gab keine Stelle in - der Oberflaeche, an der man ihn haette ansehen und freigeben koennen. Eine - Sackgasse, aus der nur ein Shell-Zugang auf den Server herausfuehrte. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - - -@pytest.fixture() -def tesm_app(instance_env: Any): - from tesm import create_app - - return create_app(TESTING=True) - - -@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 session(tclient: Any, admin: dict[str, str]): - tclient.post( - "/login", - data={ - "username": admin["username"], - "password": admin["password"], - "csrf_token": _token(tclient), - }, - follow_redirects=True, - ) - return tclient - - -@pytest.fixture() -def device(tesm_app: Any): - """Ein Client mit Zugangsdaten -- Voraussetzung fuer Wartung und Konsole.""" - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - conn.execute( - "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " - "updated_at) VALUES ('linux-login','wartung','', 'linux', datetime('now'), " - "datetime('now'))" - ) - conn.execute( - "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, " - "created_at, updated_at) VALUES ('Kamera-1','aa:bb:cc:dd:ee:01','10.9.9.9',22,1,1," - "datetime('now'), datetime('now'))" - ) - return {"id": 1, "ip": "10.9.9.9", "port": 22} - - -# --------------------------------------------------------------------------- # -# Neustart-Historie -# --------------------------------------------------------------------------- # - - -def test_restart_log_replaces_the_poe_only_page(session): - """Die alte Adresse ist weg, die neue zeigt Neustarts jeder Art.""" - assert session.get("/protokolle/poe").status_code == 404 - response = session.get("/protokolle/neustarts") - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert "Neustarts" in body - assert "PoE-Vorgaenge" not in body - - -def test_restart_log_shows_both_methods(tesm_app, session, device): - from tesm.services import monitor - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - monitor.record_restart( - conn, - device_id=1, - action="cycle", - result="ok", - trigger="auto", - actor="system", - duration_ms=1200, - detail="Port getrennt und wieder zugeschaltet", - ) - monitor.record_restart( - conn, - device_id=1, - action="reboot", - result="ok", - trigger="maintenance", - actor="root", - duration_ms=45000, - detail="Neustart ueber SSH", - method=monitor.METHOD_SSH, - ) - - body = session.get("/protokolle/neustarts").get_data(as_text=True) - assert "PoE-Port" in body - assert "SSH" in body - assert "Kamera-1" in body - - # Filter je Methode - only_ssh = session.get("/protokolle/neustarts?methode=ssh").get_data(as_text=True) - assert "maintenance" in only_ssh - assert only_ssh.count(" None: - self._output = output - self._status = status - self._sent = False - self.closed = False - - def settimeout(self, _value: float) -> None: - pass - - def set_combine_stderr(self, _value: bool) -> None: - pass - - def exec_command(self, _command: str) -> None: - pass - - def recv_ready(self) -> bool: - return not self._sent - - def recv(self, _size: int) -> bytes: - self._sent = True - return self._output - - def exit_status_ready(self) -> bool: - return self._sent - - def recv_exit_status(self) -> int: - return self._status - - def close(self) -> None: - self.closed = True - - -class FakeTransport: - def __init__(self, channel: FakeChannel) -> None: - self.channel = channel - - def is_active(self) -> bool: - return True - - def open_session(self) -> FakeChannel: - return self.channel - - def close(self) -> None: - pass - - -def test_shell_session_returns_output_and_status(): - from tesm.services.shellcli import ShellSession - - channel = FakeChannel(b"Linux poetest 6.8.0\n", 0) - session = ShellSession(None, host="h", port=22, username="u", password="p") - session._transport = FakeTransport(channel) # noqa: SLF001 - - result = session.run("uname -sr") - assert result["output"].startswith("Linux poetest") - assert result["status"] == 0 - assert result["duration_ms"] >= 0 - assert channel.closed, "Der Kanal muss geschlossen werden." - - -def test_shell_session_reports_a_failing_command(): - from tesm.services.shellcli import ShellSession - - session = ShellSession(None, host="h", port=22, username="u", password="p") - session._transport = FakeTransport(FakeChannel(b"bash: nope: not found\n", 127)) # noqa: SLF001 - result = session.run("nope") - assert result["status"] == 127 - - -def test_shell_session_refuses_a_dead_transport(): - from tesm.services.shellcli import ShellSession - from tesm.services.ssh import SshError - - session = ShellSession(None, host="h", port=22, username="u", password="p") - with pytest.raises(SshError): - session.run("uptime") - - -def test_shell_session_truncates_a_flood(): - from tesm.services.shellcli import MAX_OUTPUT_BYTES, ShellSession - - flood = b"x" * (MAX_OUTPUT_BYTES + 1024) - session = ShellSession(None, host="h", port=22, username="u", password="p") - session._transport = FakeTransport(FakeChannel(flood, 0)) # noqa: SLF001 - result = session.run("yes") - assert "abgeschnitten" in result["output"] - assert len(result["output"]) < len(flood.decode()) - - -# --------------------------------------------------------------------------- # -# Auftragsausgabe -# --------------------------------------------------------------------------- # - - -def test_job_output_keeps_one_line_per_entry(tesm_app): - """Jede Ausgabe gehoert auf eine eigene Zeile. - - ``splitlines()`` wirft das abschliessende Zeilenende weg. Ohne Korrektur - klebte jede neue Zeile an der vorherigen, und das ganze Protokoll stand am - Ende auf einer einzigen Zeile -- genau so im Betrieb aufgefallen. - """ - from tesm.services import maintenance - from tesm_core.db import Database - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - handle = maintenance.create_job( - conn, kind=maintenance.JOB_KIND_UPDATE, subject="Testgeraet", actor="root" - ) - - for line in ("--- Paketlisten aktualisieren ---", "sudo: a password is required", - "Schritt endete mit Rueckgabewert 1."): - maintenance.update_job(extension.database, handle.id, append_output=f"12:00:00 {line}\n") - - with extension.database.session() as conn: - output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) - - lines = output.splitlines() - assert len(lines) == 3, f"Erwartet drei Zeilen, bekommen: {output!r}" - assert lines[0].endswith("--- Paketlisten aktualisieren ---") - assert lines[1].endswith("sudo: a password is required") - assert "---sudo:" not in output, "Zeilen kleben aneinander." - - -def test_job_output_is_trimmed_but_stays_line_wise(tesm_app): - from tesm.services import maintenance - from tesm_core.db import Database - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - handle = maintenance.create_job( - conn, kind=maintenance.JOB_KIND_UPDATE, subject="Flut", actor="root" - ) - for index in range(maintenance.MAX_OUTPUT_LINES + 50): - maintenance.update_job(extension.database, handle.id, append_output=f"Zeile {index}\n") - - with extension.database.session() as conn: - output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) - lines = output.splitlines() - assert len(lines) == maintenance.MAX_OUTPUT_LINES - # Die juengsten Zeilen bleiben stehen, nicht die aeltesten. - assert lines[-1] == f"Zeile {maintenance.MAX_OUTPUT_LINES + 49}" - - -def test_known_failures_get_an_actionable_hint(): - """Die rohe Meldung sagt die Wahrheit, aber nicht, was zu tun ist.""" - from tesm.services.maintenance import hint_for - - # Passwort verlangt, aber keines hinterlegt -> auf die Zugangsdaten zeigen. - hint = hint_for("sudo: a password is required") - assert "Zugangsdaten" in hint - assert "NOPASSWD" not in hint, "Der Weg ueber die Zugangsdaten ist der einfachere." - - # Passwort abgewiesen -> nicht die Zugangsdaten, sondern sudo ist das Problem. - assert "abgewiesen" in hint_for("sudo: 1 incorrect password attempt") - - # Konto darf gar kein sudo -> sudoers-Regel noetig. - not_allowed = hint_for("wartung is not in the sudoers file. This incident will be reported.") - assert "sudoers" in not_allowed - assert "NOPASSWD" in not_allowed - - assert "requiretty" in hint_for("sudo: no tty present and no askpass program specified") - assert "apt-get" in hint_for("bash: apt-get: command not found") - assert "unattended" in hint_for("E: Could not get lock /var/lib/dpkg/lock-frontend") - assert hint_for("alles in Ordnung") == "" - - -def test_hint_survives_surrounding_output(): - from tesm.services.maintenance import hint_for - - output = "\n".join( - ["--- Paketlisten aktualisieren ---", "sudo: a password is required", "Ende"] - ) - assert hint_for(output) - - -# --------------------------------------------------------------------------- # -# sudo mit dem hinterlegten Passwort -# --------------------------------------------------------------------------- # - - -class SudoTransport: - """Zeichnet auf, welche Kommandos laufen und was ueber stdin hereinkommt.""" - - def __init__(self, passwordless_ok: bool) -> None: - self.passwordless_ok = passwordless_ok - self.commands: list[str] = [] - self.stdin: list[str] = [] - - def is_active(self) -> bool: - return True - - def open_session(self): # type: ignore[no-untyped-def] - return SudoChannel(self) - - def close(self) -> None: - pass - - -class SudoChannel: - def __init__(self, transport: SudoTransport) -> None: - self.transport = transport - self.command = "" - self.written = b"" - self._done = False - - def settimeout(self, _v: float) -> None: - pass - - def set_combine_stderr(self, _v: bool) -> None: - pass - - def exec_command(self, command: str) -> None: - self.command = command - self.transport.commands.append(command) - - def sendall(self, data: bytes) -> None: - self.written += data - - def shutdown_write(self) -> None: - self.transport.stdin.append(self.written.decode()) - - def recv_ready(self) -> bool: - return False - - def exit_status_ready(self) -> bool: - return True - - def recv(self, _n: int) -> bytes: - return b"" - - def recv_exit_status(self) -> int: - if self.command.startswith("sudo -n true"): - return 0 if self.transport.passwordless_ok else 1 - return 0 - - def close(self) -> None: - pass - - -def test_passwordless_sudo_keeps_the_password_on_the_server(): - """Geht es ohne, verlaesst das Passwort den Server gar nicht erst.""" - from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode - - transport = SudoTransport(passwordless_ok=True) - lines: list[str] = [] - sudo, stdin = _sudo_mode(transport, "geheim", lines.append) - - assert sudo == SUDO_PASSWORDLESS - assert stdin == "" - assert transport.stdin == [], "Ohne Not wird kein Passwort uebertragen." - assert "ohne Passwort" in " ".join(lines) - - -def test_sudo_falls_back_to_the_stored_password(): - from tesm.services.maintenance import SUDO_WITH_PASSWORD, _sudo_mode - - transport = SudoTransport(passwordless_ok=False) - lines: list[str] = [] - sudo, stdin = _sudo_mode(transport, "geheim", lines.append) - - assert sudo == SUDO_WITH_PASSWORD - assert stdin == "geheim\n" - assert "Zugangsdaten" in " ".join(lines) - - -def test_password_never_appears_on_the_command_line(): - """Auf dem Zielgeraet steht die Kommandozeile in ``ps`` -- fuer jeden lesbar.""" - from tesm.services.maintenance import ( - REBOOT_COMMAND, - UPDATE_COMMANDS, - _run_remote_command, - _sudo_mode, - ) - - transport = SudoTransport(passwordless_ok=False) - sudo, stdin = _sudo_mode(transport, "streng-geheim", lambda _line: None) - - for _label, template in UPDATE_COMMANDS: - _run_remote_command( - transport, template.format(sudo=sudo), timeout=5, on_line=lambda _l: None, - stdin_text=stdin, - ) - _run_remote_command( - transport, REBOOT_COMMAND.format(sudo=sudo), timeout=5, on_line=lambda _l: None, - stdin_text=stdin, - ) - - assert all("streng-geheim" not in command for command in transport.commands), ( - "Das Passwort darf nie in einem Kommando stehen." - ) - # Es geht ausschliesslich ueber stdin. - assert transport.stdin, "Das Passwort muss ueber stdin gehen." - assert all(entry == "streng-geheim\n" for entry in transport.stdin) - - -def test_sudo_prompt_is_suppressed(): - """Ohne ``-p ''`` landet die Passwortaufforderung mitten in der Ausgabe.""" - from tesm.services.maintenance import SUDO_WITH_PASSWORD - - assert "-S" in SUDO_WITH_PASSWORD - assert "-p ''" in SUDO_WITH_PASSWORD - assert "-k" in SUDO_WITH_PASSWORD - - -def test_commands_carry_no_hardcoded_sudo(): - """Das Verfahren wird zur Laufzeit gewaehlt -- nicht in der Konstante festgelegt.""" - from tesm.services.maintenance import REBOOT_COMMAND, UPDATE_COMMANDS - - for _label, template in UPDATE_COMMANDS: - assert "{sudo}" in template - assert "sudo -n" not in template - assert "{sudo}" in REBOOT_COMMAND - - -def test_without_a_stored_password_nothing_is_invented(): - from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode - - transport = SudoTransport(passwordless_ok=False) - lines: list[str] = [] - sudo, stdin = _sudo_mode(transport, "", lines.append) - assert sudo == SUDO_PASSWORDLESS - assert stdin == "" - assert "keines hinterlegt" in " ".join(lines) - - -# --------------------------------------------------------------------------- # -# Rueckkehr nach einem Neustart -# --------------------------------------------------------------------------- # - - -def test_reboot_waits_on_tcp_not_on_icmp(): - """ICMP kann der Webprozess gar nicht. - - Die systemd-Unit der Weboberflaeche hat ein leeres - Die systemd-Unit der Weboberflaeche bekommt keine AmbientCapabilities -- - damit greift die Datei-Capability von ``/usr/bin/ping`` nicht. Jeder Ping - schlaegt dort fehl, und der Wartungsauftrag lief deshalb *immer* in die - Zeitueberschreitung, obwohl das Geraet laengst zurueck war. - """ - import inspect - - from tesm.services import maintenance - - source = inspect.getsource(maintenance.run_reboot) - assert "tcp_reachable(" in source - assert "ping(" not in source, "ICMP ist im Webprozess nicht verfuegbar." - - -def test_web_unit_has_no_net_raw_but_the_monitor_does(): - """Die Aufteilung ist Absicht und muss so bleiben.""" - from pathlib import Path - - root = Path(__file__).resolve().parent.parent / "deploy" / "systemd" - web = (root / "tesm.service").read_text(encoding="utf-8") - monitor = (root / "tesm-monitor.service").read_text(encoding="utf-8") - - # Kein CapabilityBoundingSet mehr -- es bricht sudo (gemessen). Was den - # Webprozess von ICMP fernhaelt, ist das Fehlen von AmbientCapabilities. - assert "CapabilityBoundingSet=" not in web - assert "AmbientCapabilities=CAP_NET_RAW" not in web, ( - "Der Webprozess soll keine Rohsockets duerfen -- er ist der exponierte." - ) - assert "AmbientCapabilities=CAP_NET_RAW" in monitor, ( - "Der Ueberwachungsdienst braucht CAP_NET_RAW fuer ICMP." - ) - - -def test_tcp_probe_needs_no_privileges(): - import socket - import threading - - from tesm.services.monitor import tcp_reachable - - server = socket.socket() - server.bind(("127.0.0.1", 0)) - server.listen(1) - port = server.getsockname()[1] - threading.Thread(target=lambda: server.accept(), daemon=True).start() - - assert tcp_reachable("127.0.0.1", port, timeout=2) - server.close() - - -def test_tcp_probe_reports_a_closed_port(): - from tesm.services.monitor import tcp_reachable - - # Port 1 ist auf keinem sinnvoll konfigurierten System offen. - assert not tcp_reachable("127.0.0.1", 1, timeout=1) - - -# --------------------------------------------------------------------------- # -# Keine geschachtelten Datenbankverbindungen -# --------------------------------------------------------------------------- # - - -def test_restart_takes_the_keystore_not_a_callback(): - """Ein Rueckruf kannte die offene Verbindung nicht und oeffnete eine neue. - - Der Ueberwachungsdienst starb dadurch bei jedem automatischen PoE-Neustart - mit ``NestedConnectionError`` -- und der Geraetestatus blieb stehen. Mit dem - Schluesselspeicher als Argument gibt es die falsche Variante nicht mehr. - """ - import inspect - - from tesm.services import monitor - - for function in (monitor.restart_device_poe, monitor.run_sweep): - signature = inspect.signature(function) - assert "keystore" in signature.parameters, function.__name__ - assert "decrypt_secret" not in signature.parameters, function.__name__ - - source = inspect.getsource(monitor.restart_device_poe) - assert "credential_secret(conn," in source, "Die vorhandene Verbindung muss benutzt werden." - - -def test_no_caller_passes_a_decrypt_callback(): - from pathlib import Path - - root = Path(__file__).resolve().parent.parent / "apps" / "tesm" / "src" / "tesm" - offenders = [ - str(path.relative_to(root)) - for path in root.rglob("*.py") - if "__pycache__" not in path.parts and "decrypt_secret" in path.read_text(encoding="utf-8") - ] - assert not offenders, f"Rueckruf lebt noch in: {offenders}" - - -def test_sweep_survives_a_restart_without_nested_connection(tesm_app, device): - """Der ganze Durchlauf, inklusive Neustartversuch, ohne zweite Verbindung.""" - from tesm.services import monitor - from tesm_core.extension import core - - extension = core(tesm_app) - live = extension.registry["live_log"] - - with tesm_app.app_context(): - report = monitor.run_sweep( - database=extension.database, - live=live, - keystore=extension.keystore, - license_allows_auto_restart=True, - failures_before_restart=1, - ) - # Das Geraet ist nicht erreichbar -- entscheidend ist, dass der Durchlauf - # ueberhaupt durchlaeuft und nicht an einer geschachtelten Verbindung stirbt. - assert report.checked >= 1 - - -# --------------------------------------------------------------------------- # -# Windows: kein SSH, kein Host-Schluessel -# --------------------------------------------------------------------------- # - - -def test_windows_is_not_an_ssh_target(): - from tesm.services import inventory - - assert inventory.is_ssh_managed("linux") - assert inventory.is_ssh_managed("switch") - assert inventory.is_ssh_managed(None) - assert not inventory.is_ssh_managed("windows") - - -def test_windows_devices_do_not_appear_in_the_host_key_page(tesm_app, session): - """Sonst stuenden sie fuer immer auf "Freigabe offen".""" - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - conn.execute( - "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " - "updated_at) VALUES ('win','admin','','windows',datetime('now'),datetime('now'))" - ) - conn.execute( - "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " - "updated_at) VALUES ('lin','wartung','','linux',datetime('now'),datetime('now'))" - ) - conn.execute( - "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " - "updated_at) VALUES ('PC-Win','aa:bb:cc:00:00:01','10.5.5.1',22,1,1,datetime('now')," - "datetime('now'))" - ) - conn.execute( - "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " - "updated_at) VALUES ('PC-Linux','aa:bb:cc:00:00:02','10.5.5.2',22,2,1,datetime('now')," - "datetime('now'))" - ) - - body = session.get("/hostschluessel/").get_data(as_text=True) - assert "PC-Linux" in body - assert "PC-Win" not in body, "Windows braucht keinen Host-Schluessel." - - -def test_windows_reboot_uses_rpc_not_ssh(): - import inspect - - from tesm.services import maintenance - - source = inspect.getsource(maintenance.run_reboot) - assert "_reboot_windows(" in source - assert "WINDOWS_PROBE_PORT" in source - # Der SSH-Zweig darf fuer Windows gar nicht erst betreten werden. - assert "if is_windows:" in source - - -def test_windows_password_never_reaches_the_command_line(monkeypatch, tmp_path): - """``net -U benutzer%pass`` waere in ``ps`` sichtbar -- auf beiden Seiten.""" - import subprocess - - from tesm.services import windowsops - - seen: dict[str, object] = {} - - def fake_run(command, **kwargs): - seen["command"] = command - authfile = command[command.index("--authentication-file") + 1] - seen["authfile_content"] = pathlib_read(authfile) - return subprocess.CompletedProcess(command, 0, "Shutdown ausgeloest", "") - - def pathlib_read(path: str) -> str: - with open(path, encoding="utf-8") as handle: - return handle.read() - - monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") - monkeypatch.setattr(windowsops.subprocess, "run", fake_run) - - result = windowsops.reboot("10.5.5.1", "administrator", "streng-geheim") - assert result.ok - - command = " ".join(str(part) for part in seen["command"]) - assert "streng-geheim" not in command, "Passwort steht in der Kommandozeile." - assert "%" not in command - # Es geht ausschliesslich ueber die Anmeldedatei. - assert "password=streng-geheim" in str(seen["authfile_content"]) - - -def test_windows_auth_file_is_removed_afterwards(monkeypatch): - import os - import subprocess - - from tesm.services import windowsops - - captured: dict[str, str] = {} - - def fake_run(command, **kwargs): - captured["path"] = command[command.index("--authentication-file") + 1] - return subprocess.CompletedProcess(command, 0, "", "") - - monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") - monkeypatch.setattr(windowsops.subprocess, "run", fake_run) - windowsops.reboot("10.5.5.1", "administrator", "geheim") - - assert not os.path.exists(captured["path"]), "Die Anmeldedatei muss verschwinden." - - -def test_windows_domain_prefix_is_split(): - from tesm.services import windowsops - - captured: dict[str, str] = {} - - class FakeCompleted: - returncode = 0 - stdout = "" - stderr = "" - - def fake_run(command, **kwargs): - with open(command[command.index("--authentication-file") + 1], encoding="utf-8") as handle: - captured["content"] = handle.read() - return FakeCompleted() - - import subprocess - - original_which = windowsops.shutil.which - original_run = windowsops.subprocess.run - windowsops.shutil.which = lambda _name: "/usr/bin/net" - windowsops.subprocess.run = fake_run - try: - windowsops.reboot("10.5.5.1", "EERTMOED" + chr(92) + "administrator", "geheim") - finally: - windowsops.shutil.which = original_which - windowsops.subprocess.run = original_run - - assert "username=administrator" in captured["content"] - assert "domain=EERTMOED" in captured["content"] - assert subprocess is not None - - -def test_missing_samba_is_explained_not_silently_ignored(monkeypatch): - from tesm.services import windowsops - - monkeypatch.setattr(windowsops.shutil, "which", lambda _name: None) - result = windowsops.reboot("10.5.5.1", "administrator", "geheim") - assert not result.ok - assert windowsops.REQUIRED_PACKAGE in result.hint - - -def test_windows_errors_get_a_hint(): - from tesm.services.windowsops import hint_for - - assert "DOMAENE" in hint_for("NT_STATUS_LOGON_FAILURE") - assert "Remotesystem" in hint_for("NT_STATUS_ACCESS_DENIED") - assert "445" in hint_for("NT_STATUS_CONNECTION_REFUSED") - assert hint_for("alles gut") == "" - - -def test_samba_package_is_installable_through_the_helper(): - from pathlib import Path - - helper = (Path(__file__).resolve().parent.parent / "deploy" / "tesm-helper").read_text( - encoding="utf-8" - ) - from tesm.services.windowsops import REQUIRED_PACKAGE - - assert REQUIRED_PACKAGE in helper, "Sonst laesst es sich nicht nachinstallieren." - - -# --------------------------------------------------------------------------- # -# Seiten mit Inhalt rendern -# --------------------------------------------------------------------------- # - - -@pytest.fixture() -def populated(tesm_app: Any): - """Ein Bestand, der jede Verzweigung der Vorlagen erreicht. - - Ein Rauchtest auf leeren Seiten prueft zu wenig: Schleifenkoerper laufen - dort nie. Genau so ist eine fehlende Vorlagenvariable durchgerutscht -- - lokal gruen, auf dem Zielsystem HTTP 500, sobald der erste Datensatz da war. - """ - from tesm_core.extension import core - - extension = core(tesm_app) - with extension.database.session() as conn, extension.database.transaction(conn): - for index, (name, category) in enumerate( - [("sw-login", "switch"), ("linux-login", "linux"), ("win-login", "windows")], start=1 - ): - conn.execute( - "INSERT INTO credentials (name, username, secret_encrypted, category, " - "created_at, updated_at) VALUES (?,?,'',?,datetime('now'),datetime('now'))", - (name, f"user{index}", category), - ) - conn.execute( - "INSERT INTO switches (hostname, ip, ssh_port, credential_id, created_at, updated_at) " - "VALUES ('sw-1','10.7.0.1',22,1,datetime('now'),datetime('now'))" - ) - for index, (name, credential) in enumerate( - [("Linux-PC", 2), ("Windows-PC", 3), ("Ohne-Login", None)], start=1 - ): - conn.execute( - "INSERT INTO devices (name, mac, ip, ssh_port, switch_id, port, credential_id, " - "is_active, created_at, updated_at) " - "VALUES (?,?,?,22,1,?,?,1,datetime('now'),datetime('now'))", - (name, f"aa:bb:cc:00:0{index}:01", f"10.7.0.{10 + index}", str(index), credential), - ) - conn.execute( - "INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) " - "VALUES ('10.7.0.1',22,'ssh-ed25519',?,datetime('now'),'test')", - ("SHA256:" + "A" * 43,), - ) - return True - - -PAGES_WITH_CONTENT = [ - "/", - "/clients/", - "/clients/1", - "/switche/", - "/switche/1", - "/zugangsdaten/", - "/hostschluessel/", - "/protokolle/neustarts", - "/papierkorb/", - "/diagnose/", -] - - -@pytest.mark.parametrize("path", PAGES_WITH_CONTENT) -def test_pages_render_with_real_data(session, populated, path): - response = session.get(path) - body = response.get_data(as_text=True) - assert response.status_code == 200, f"{path}: HTTP {response.status_code}" - assert "Interner Fehler" not in body, path - # Eine fehlende Vorlagenvariable schlaegt als UndefinedError durch -- die - # Fehlerseite nennt sie beim Namen. - assert "is undefined" not in body, path - - -def test_maintenance_pages_render_with_real_data(session, populated, licensed): - for path in ("/wartung/", "/dateifreigaben/", "/dhcp/"): - response = session.get(path) - assert response.status_code == 200, f"{path}: HTTP {response.status_code}" - assert "Interner Fehler" not in response.get_data(as_text=True), path - - -def test_device_form_offers_creating_credentials(session, populated): - body = session.get("/clients/").get_data(as_text=True) - assert "neue Zugangsdaten anlegen" in body - assert "new_credential_name" in body - assert "new_credential_secret" in body - - -def test_new_device_can_bring_its_own_credentials(session, populated, tesm_app): - from tesm_core.db import Database - from tesm_core.extension import core - - response = session.post( - "/clients/neu", - data={ - "csrf_token": _token(session, "/clients/"), - "name": "Frisch", - "mac": "aa:bb:cc:11:22:33", - "ip": "10.7.0.90", - "ssh_port": "22", - "credential_id": "__neu__", - "new_credential_name": "Frische Zugangsdaten", - "new_credential_username": "wartung", - "new_credential_secret": "Ein-Passwort-1", - "new_credential_category": "linux", - "is_active": "1", - }, - follow_redirects=True, - ) - assert "angelegt" in response.get_data(as_text=True) - - extension = core(tesm_app) - with extension.database.session() as conn: - credential = Database.one( - conn, "SELECT * FROM credentials WHERE name='Frische Zugangsdaten'" - ) - device = Database.one(conn, "SELECT * FROM devices WHERE name='Frisch'") - assert credential is not None, "Die Zugangsdaten muessen mit angelegt werden." - assert device is not None - assert device["credential_id"] == credential["id"], "Sie muessen auch zugeordnet sein." - assert credential["secret_encrypted"], "Das Passwort muss verschluesselt gespeichert sein." - - -def test_a_nameless_new_credential_blocks_the_device(session, populated, tesm_app): - """Halb ausgefuehrt waere schlimmer als gar nicht: das Geraet haette dann - keine Zugangsdaten, obwohl welche gewollt waren.""" - from tesm_core.db import Database - from tesm_core.extension import core - - response = session.post( - "/clients/neu", - data={ - "csrf_token": _token(session, "/clients/"), - "name": "Halb", - "mac": "aa:bb:cc:44:55:66", - "ip": "10.7.0.91", - "ssh_port": "22", - "credential_id": "__neu__", - "new_credential_name": "", - "is_active": "1", - }, - follow_redirects=True, - ) - assert "Bezeichnung" in response.get_data(as_text=True) - - extension = core(tesm_app) - with extension.database.session() as conn: - assert Database.one(conn, "SELECT * FROM devices WHERE name='Halb'") is None - - -def test_sweep_holds_no_write_lock_across_the_ssh_session(): - """Eine SSH-Sitzung zum Switch dauert Sekunden -- eine Schreibsperre auch. - - Auf POETEST dauerte ein Durchlauf mit drei nicht erreichbaren Switchen - knapp 40 Sekunden, und die Oberflaeche antwortete waehrenddessen mit - "database is locked". Die langsame Netzarbeit gehoert deshalb ausserhalb - der Transaktion; geschrieben wird kurz und am Ende. - """ - import inspect - - from tesm.services import monitor - - source = inspect.getsource(monitor.run_sweep) - assert "with database.session() as conn, database.transaction(conn):\n outcome" not in source - assert "database=database," in source, "Die Aufzeichnung braucht eigene Transaktionen." - - restart = inspect.getsource(monitor.restart_device_poe) - assert "with write():" in restart - # Die SSH-Sitzung darf in keiner Transaktion stehen. - ssh_line = restart.index("with SwitchCli(") - assert "with write():" not in restart[max(0, ssh_line - 400) : ssh_line] - - -# --------------------------------------------------------------------------- # -# Neustart: mehrere Wege, klare Rangfolge -# --------------------------------------------------------------------------- # - - -def _device(**overrides): - base = { - "id": 1, - "name": "Testgeraet", - "switch_id": None, - "port": "", - "credential_id": None, - "credential_category": "", - } - return {**base, **overrides} - - -def test_poe_wins_when_several_ways_are_open(): - """PoE zuerst: es wirkt auch, wenn das Betriebssystem nicht mehr antwortet.""" - from tesm.services import restart - - device = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") - options = restart.available(device) - assert [entry.key for entry in options] == ["poe", "ssh"] - assert restart.preferred(device).key == "poe" - - -def test_only_ssh_when_there_is_no_port(): - from tesm.services import restart - - device = _device(credential_id=2, credential_category="linux") - assert [entry.key for entry in restart.available(device)] == ["ssh"] - - -def test_windows_offers_rpc_never_ssh(): - from tesm.services import restart - - device = _device(credential_id=2, credential_category="windows") - assert [entry.key for entry in restart.available(device)] == ["rpc"] - - -def test_a_switch_without_a_port_is_not_a_way(): - """Ein Knopf, der verlaesslich nichts bewirkt, ist schlimmer als kein Knopf.""" - from tesm.services import restart - - assert restart.available(_device(switch_id=1, port="")) == [] - assert restart.available(_device(switch_id=None, port="7")) == [] - - -def test_nothing_configured_means_no_button(): - from tesm.services import restart - - assert restart.available(_device()) == [] - assert restart.preferred(_device()) is None - - -def test_ssh_and_rpc_need_the_maintenance_module(): - """PoE bleibt frei -- ein haengendes Geraet muss man immer neu starten koennen.""" - from tesm.services import restart - - linux = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") - assert [entry.key for entry in restart.available(linux, maintenance_active=False)] == ["poe"] - - windows = _device(credential_id=2, credential_category="windows") - assert restart.available(windows, maintenance_active=False) == [] - - -def test_an_impossible_request_is_refused_not_substituted(): - """Sonst startet jemand ein Geraet anders neu, als er dachte.""" - from tesm.services import restart - - device = _device(switch_id=1, port="7") - assert restart.resolve(device, "ssh") is None - assert restart.resolve(device, "poe").key == "poe" - # Ohne Angabe gilt die Rangfolge. - assert restart.resolve(device, "").key == "poe" - - -def test_restart_route_refuses_an_unavailable_method(session, populated): - """Geraet 1 ("Linux-PC") haengt an Switch 1 Port 1 und hat Linux-Zugangsdaten.""" - response = session.post( - "/clients/3/neustart", - data={"csrf_token": _token(session, "/clients/"), "method": "poe"}, - follow_redirects=True, - ) - body = response.get_data(as_text=True) - # Geraet 3 ("Ohne-Login") hat Switch und Port, aber keine Zugangsdaten -- - # PoE geht, SSH nicht. Umgekehrt geprueft: - response = session.post( - "/clients/3/neustart", - data={"csrf_token": _token(session, "/clients/"), "method": "ssh"}, - follow_redirects=True, - ) - assert "steht fuer dieses Geraet nicht zur Verfuegung" in response.get_data(as_text=True) - assert body is not None - - -def test_device_list_shows_the_credential_category(session, populated): - body = session.get("/clients/").get_data(as_text=True) - assert "Linux-Client" in body - assert "Windows-Client" in body - - -def test_restart_buttons_appear_per_available_way(session, populated, licensed): - body = session.get("/clients/").get_data(as_text=True) - assert "/neustart" in body - assert "PoE-Port" in body - assert "Neustart ueber RPC" in body - - -def test_the_old_poe_only_endpoint_is_gone(): - """Ein zweiter Endpunkt fuer denselben Zweck waere eine Verzweigung zu viel.""" - from tesm import create_app - - rules = {rule.rule for rule in create_app(TESTING=True).url_map.iter_rules()} - assert "/clients//neustart" in rules - assert "/clients//poe-neustart" not in rules diff --git a/tests/test_tesm_smoke.py b/tests/test_tesm_smoke.py deleted file mode 100644 index 4c4b0b6..0000000 --- a/tests/test_tesm_smoke.py +++ /dev/null @@ -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" diff --git a/tests/test_webserver.py b/tests/test_webserver.py deleted file mode 100644 index 964e046..0000000 --- a/tests/test_webserver.py +++ /dev/null @@ -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"