Files
tesm/tests/test_core_security.py
T
alientimandClaude Opus 5 7354ff352b 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>
2026-09-02 18:00:06 +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"