Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * 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. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * 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
|