TESM 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen
Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich
jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:00:06 +02:00
co-authored by Claude Opus 5
parent a193b9d6e9
commit 7354ff352b
227 changed files with 37605 additions and 21187 deletions
+246
View File
@@ -0,0 +1,246 @@
"""Rauchtest des Grundgeruests: Start, Anmeldung, Rechte, CSRF, Header."""
from __future__ import annotations
import re
def test_app_starts_and_health_ok(client):
response = client.get("/gesundheit")
assert response.status_code == 200
assert response.get_json()["status"] == "ok"
def test_login_page_renders_and_sets_csrf(client):
response = client.get("/login")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "Anmelden" in body
assert 'name="csrf_token"' in body
def test_security_headers_present(client):
response = client.get("/login")
csp = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in csp
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
assert re.search(r"nonce-[A-Za-z0-9_-]{10,}", csp)
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert "camera=()" in response.headers["Permissions-Policy"]
def test_login_and_logout(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
response = login("anna", "Ein-gutes-Passwort-1")
assert response.status_code == 200
assert "Willkommen" in response.get_data(as_text=True)
page = client.get("/konto/")
assert page.status_code == 200
assert "anna" in page.get_data(as_text=True)
def test_wrong_password_is_generic(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
response = login("anna", "falsch")
body = response.get_data(as_text=True)
assert "Benutzername oder Passwort ist falsch" in body
# Kein Hinweis darauf, dass das Konto existiert.
assert "gesperrt" not in body
def test_unknown_user_same_message(client, login):
body = login("gibtsnicht", "irgendwas").get_data(as_text=True)
assert "Benutzername oder Passwort ist falsch" in body
def test_locked_account_rejected(client, make_user, login):
make_user("gesperrt", "Ein-gutes-Passwort-1", is_locked=True)
body = login("gesperrt", "Ein-gutes-Passwort-1").get_data(as_text=True)
assert "gesperrt" in body
def test_rate_limit_blocks_after_many_attempts(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
messages = [login("anna", "falsch").get_data(as_text=True) for _ in range(9)]
assert any("voruebergehend gesperrt" in body for body in messages)
def test_protected_page_redirects_to_login(client):
response = client.get("/widgets")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_permission_required_blocks_without_grant(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
assert client.get("/verwaltung/benutzer").status_code == 403
def test_permission_granted_allows_access(client, make_user, login):
make_user("bob", "Ein-gutes-Passwort-1", permissions=("users.view",))
login("bob", "Ein-gutes-Passwort-1")
response = client.get("/verwaltung/benutzer")
assert response.status_code == 200
assert "Benutzer" in response.get_data(as_text=True)
def test_admin_sees_everything(client, make_user, login):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
for path in ("/verwaltung/benutzer", "/verwaltung/gruppen", "/papierkorb/", "/diagnose/"):
assert client.get(path).status_code == 200, path
def test_csrf_missing_token_is_rejected(client, make_user, login):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
response = client.post("/verwaltung/benutzer/neu", data={"username": "boese"})
assert response.status_code == 403
assert "Sicherheitstoken" in response.get_data(as_text=True)
def test_csrf_wrong_origin_is_rejected(client, make_user, login, csrf_token):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
token = csrf_token("/verwaltung/benutzer")
response = client.post(
"/verwaltung/benutzer/neu",
data={"username": "boese", "csrf_token": token},
headers={"Origin": "https://angreifer.example"},
)
assert response.status_code == 403
def test_nav_hides_pages_without_permission(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
body = client.get("/").get_data(as_text=True)
assert "Diagnose" not in body
assert "Widgets" not in body
def test_nav_shows_pages_with_permission(client, make_user, login):
make_user("bob", "Ein-gutes-Passwort-1", permissions=("widgets.view",))
login("bob", "Ein-gutes-Passwort-1")
body = client.get("/").get_data(as_text=True)
assert "Widgets" in body
def test_default_group_created_from_preset(core_app):
from tesm_core.extension import core
from tesm_core.rbac import service as rbac
extension = core(core_app)
with extension.database.session() as conn:
groups = rbac.list_groups(conn)
names = {group.name for group in groups}
assert "Benutzer" in names
default = next(group for group in groups if group.name == "Benutzer")
assert default.is_default is True
assert "widgets.view" in default.permissions
assert "demo.view" in default.permissions # Bereichsschalter wird mitgesetzt
def test_error_page_shows_request_id(client):
response = client.get("/gibt-es-nicht")
assert response.status_code == 404
body = response.get_data(as_text=True)
assert "404" in body
assert "Vorgangsnummer" in body
# --------------------------------------------------------------------------- #
# Verzeichnisgruppen: suchen statt abtippen
# --------------------------------------------------------------------------- #
def test_group_search_is_open_to_fileshare_editors():
"""Wer Freigaben zuordnet, muss die Gruppen finden koennen.
Sonst braeuchte er zusaetzlich das Recht, den Verzeichnisdienst zu
konfigurieren -- eine Rechteausweitung fuer eine reine Lesesuche.
"""
import inspect
from tesm_core.views import hostsettings
source = inspect.getsource(hostsettings.directory_group_search)
assert "fileshare.edit" in source
assert 'mode="any"' in source or "mode='any'" in source
def test_the_picker_never_forces_typing_a_dn():
"""Ein abgetippter DN ist ein Tippfehler, der erst bei der Anmeldung auffaellt."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent
picker = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "_group_picker.html"
).read_text(encoding="utf-8")
assert "directory_group_search" in picker
assert "data-group-query" in picker
# Tippen bleibt trotzdem moeglich: ohne Dienstkonto gibt es keine Suche.
assert 'name="{{ dn_field }}"' in picker
for template in (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "settings_directory.html",
root / "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "fileshare_mappings.html",
):
text = template.read_text(encoding="utf-8")
assert "group_picker(" in text, template.name
def test_transport_choice_carries_its_port():
"""Umgestellter Transport mit altem Port ergibt einen Fehler ohne Aussage."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent
page = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "settings_directory.html"
).read_text(encoding="utf-8")
assert 'data-behavior="transport-port"' in page
assert 'data-port-target="#ldap-port"' in page
script = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "static"
/ "tesm_core" / "js" / "app.js"
).read_text(encoding="utf-8")
assert "transport-port" in script
assert 'ldaps: "636"' in script
assert 'starttls: "389"' in script
# Ein absichtlich abweichender Port darf nicht ueberschrieben werden.
assert "known.includes(port.value)" in script
def test_permission_matrix_has_no_nameless_checkbox(client, make_user, login):
"""In der Rechtematrix darf kein Kaestchen ohne ``name`` stehen.
Fuer nicht vorgesehene Aktionen stand dort ein deaktiviertes Kaestchen.
Das JS hat beim Einschalten des Bereichs *alle* Kaestchen der Zeile
freigegeben -- auch dieses. Es liess sich anhaken, uebertrug wegen des
fehlenden ``name`` aber nichts: es sah erteilt aus und war nach dem
Speichern verschwunden. Ohne Eingabefeld kann das nicht wieder passieren.
"""
import re
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
body = client.get("/verwaltung/gruppen").get_data(as_text=True)
if "permission-matrix" not in body:
gruppe = re.search(r'href="(/verwaltung/gruppen/\d+)"', body)
assert gruppe, "keine Gruppe zum Pruefen gefunden"
body = client.get(gruppe.group(1)).get_data(as_text=True)
assert "permission-matrix" in body, "Rechtematrix nicht gefunden"
kaestchen = re.findall(r"<input[^>]*type=\"checkbox\"[^>]*>", body)
assert kaestchen, "keine Kaestchen in der Matrix"
ohne_namen = [k for k in kaestchen if "name=" not in k]
assert not ohne_namen, f"Kaestchen ohne name: {ohne_namen[:3]}"
# Der Platzhalter ist jetzt reine Darstellung.
assert "matrix__na" in body