Files
tesm-license/tests/test_core_security.py
T
alientimandClaude Opus 5 f7805a2180 TESM-Lizenzserver 2.0.0 -- Neubau
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>
2026-09-02 18:01:23 +02:00

722 lines
27 KiB
Python

"""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"