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>
247 lines
9.4 KiB
Python
247 lines
9.4 KiB
Python
"""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
|