"""Tests des Lizenzservers: Ausstellung, Aktivierung, Portal, Rollen. Deckt die Regeln ab, die der Vorgaenger schmerzhaft gelernt hat -- und die Haertungen, die hier neu sind (Replay, Nonce, Rollen-Sperre, Portal-TTL). """ from __future__ import annotations import json from typing import Any import pytest import tesm_licensing as lic @pytest.fixture() def ls_app(instance_env: Any): from tesm_license import create_app return create_app(TESTING=True) @pytest.fixture() def ls_client(ls_app: Any): return ls_app.test_client() @pytest.fixture() def ls_admin(ls_app: Any): from tesm_core.extension import core from tesm_core.security import passwords extension = core(ls_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, " "updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))", (passwords.hash_password("Ein-gutes-Passwort-1"),), ) return {"username": "root", "password": "Ein-gutes-Passwort-1"} def _token(client: Any, path: str = "/login") -> str: body = client.get(path).get_data(as_text=True) for marker in ('name="csrf_token" value="', 'data-csrf="'): if marker in body: start = body.index(marker) + len(marker) return body[start : body.index('"', start)] raise AssertionError(f"Kein CSRF-Token in {path}") @pytest.fixture() def ls_login(ls_client: Any, ls_admin: dict[str, str]): def do() -> Any: return ls_client.post( "/login", data={ "username": ls_admin["username"], "password": ls_admin["password"], "csrf_token": _token(ls_client), }, follow_redirects=True, ) return do @pytest.fixture() def bootstrapped(ls_app: Any, ls_client: Any, ls_login: Any): """Angemeldeter Admin mit Signaturschluessel und Vendor-Angaben.""" from tesm_core.extension import core ls_login() extension = core(ls_app) with ls_app.app_context(), extension.database.session() as conn: with extension.database.transaction(conn): extension.settings.set(conn, "vendor_name", "WiS GmbH", actor="test") extension.settings.set( conn, "master_endpoint", "https://lizenz.example.invalid", actor="test" ) return ls_client def _create_customer(client: Any, name: str = "Beispiel AG") -> int: token = _token(client, "/kunden/") client.post( "/kunden/neu", data={ "csrf_token": token, "name": name, "contact_email": "it@example.invalid", "contact_person": "Frau Muster", }, follow_redirects=True, ) return 1 def _create_ticket(client: Any, customer_id: int = 1, **overrides: Any) -> str: token = _token(client, "/tickets/neu") data = { "csrf_token": token, "customer_id": str(customer_id), "type": "standard", "valid_days": "365", } data.update(overrides) response = client.post("/tickets/neu", data=data, follow_redirects=False) assert response.status_code == 302, response.get_data(as_text=True)[:400] return response.headers["Location"].rstrip("/").split("/")[-1] # --------------------------------------------------------------------------- # # Start # --------------------------------------------------------------------------- # def test_app_boots(ls_app): from tesm_core.extension import core components = {row["component"] for row in core(ls_app).migration_status()} assert components == {"core", "tesm_license"} def test_dashboard_requires_login(ls_client): """Anders als TESMs Geraeteboard ist diese Startseite intern.""" response = ls_client.get("/") assert response.status_code == 302 assert "/login" in response.headers["Location"] LS_PAGES = [ "/", "/kunden/", "/tickets/", "/tickets/neu", "/lizenzen/", "/lizenzen/offline-code", "/lizenzen/sub-lizenzserver", "/einstellungen/system", "/einstellungen/anbieter", "/einstellungen/email", "/einstellungen/netzwerk", "/einstellungen/webserver", "/einstellungen/verzeichnisdienst", "/eigene-lizenz/", "/eigene-lizenz/signaturschluessel", "/sicherung/", "/papierkorb/", "/diagnose/", "/protokolle/laufend", "/protokolle/aenderungen", "/verwaltung/benutzer", "/verwaltung/gruppen", ] @pytest.mark.parametrize("path", LS_PAGES) def test_pages_render(bootstrapped, path): response = bootstrapped.get(path) assert response.status_code == 200, f"{path} -> {response.status_code}" assert "Interner Fehler" not in response.get_data(as_text=True) def test_portal_pages_public(ls_client): assert ls_client.get("/portal/").status_code == 200 assert ls_client.get("/api/v1/licenses/hinweis").get_json()["service"] == "tesm-license" # --------------------------------------------------------------------------- # # Kunde, Ticket, Lizenz # --------------------------------------------------------------------------- # def test_customer_ticket_license_flow(bootstrapped, ls_app): from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client, type="enterprise") detail = client.get(f"/tickets/{ticket_id}").get_data(as_text=True) assert "Lizenz ausstellen" in detail response = client.post( f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": _token(client, f"/tickets/{ticket_id}")}, follow_redirects=True, ) assert "ausgestellt" in response.get_data(as_text=True) extension = core(ls_app) with extension.database.session() as conn: rows = issuance.list_licenses(conn) assert len(rows) == 1 document = json.loads(str(rows[0]["license_document"])) lic.verify_license(document) assert set(document["modules"]) == set(lic.ALL_MODULES) assert document["role"] == "customer" assert rows[0]["client_key_encrypted"] assert "client_key" not in json.dumps(document) def test_only_one_open_license_per_ticket(bootstrapped, ls_app): from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client) token = _token(client, f"/tickets/{ticket_id}") client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True) second = client.post( f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True ) assert "bereits eine Lizenz offen" in second.get_data(as_text=True) extension = core(ls_app) with extension.database.session() as conn: assert len(issuance.list_licenses(conn)) == 1 def test_trial_override_does_not_change_ticket(bootstrapped, ls_app): from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client, type="standard", valid_days="730") client.post( f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": _token(client, f"/tickets/{ticket_id}"), "as_trial": "1"}, follow_redirects=True, ) extension = core(ls_app) with extension.database.session() as conn: ticket = issuance.get_ticket(conn, ticket_id) row = issuance.list_licenses(conn)[0] assert ticket["type"] == "standard" assert ticket["valid_days"] == 730 assert row["type"] == "trial" assert set(json.loads(str(row["modules"]))) == set(lic.ALL_MODULES) def test_trial_lifetime_rejected(bootstrapped): client = bootstrapped _create_customer(client) response = client.post( "/tickets/neu", data={ "csrf_token": _token(client, "/tickets/neu"), "customer_id": "1", "type": "trial", "valid_days": "30", "lifetime": "1", }, follow_redirects=True, ) assert "nicht unbefristet" in response.get_data(as_text=True) def test_ticket_delete_blocked_with_open_license(bootstrapped): client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client) token = _token(client, f"/tickets/{ticket_id}") client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True) response = client.post( f"/tickets/{ticket_id}/loeschen", data={"csrf_token": token}, follow_redirects=True ) assert "offene Lizenz" in response.get_data(as_text=True) def test_customer_delete_blocked_with_tickets(bootstrapped): client = bootstrapped _create_customer(client) _create_ticket(client) response = client.post( "/kunden/1/loeschen", data={"csrf_token": _token(client, "/kunden/1")}, follow_redirects=True, ) assert "Ticket" in response.get_data(as_text=True) def test_blocked_ticket_prevents_issuing(bootstrapped): client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client) token = _token(client, f"/tickets/{ticket_id}") client.post(f"/tickets/{ticket_id}/sperre", data={"csrf_token": token}, follow_redirects=True) response = client.post( f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True ) assert "gesperrt" in response.get_data(as_text=True) # --------------------------------------------------------------------------- # # Rollen-Sperre # --------------------------------------------------------------------------- # def test_license_server_role_downgraded_without_master_license(bootstrapped, ls_app): """Ohne eigene Master-Lizenz wird die Rolle still auf 'customer' gesetzt.""" from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client, role="license_server") extension = core(ls_app) with extension.database.session() as conn: ticket = issuance.get_ticket(conn, ticket_id) assert ticket["role"] == "customer" def test_ticket_new_hides_license_server_option(bootstrapped): body = bootstrapped.get("/tickets/neu").get_data(as_text=True) assert "Lizenzserver-Lizenz" not in body # --------------------------------------------------------------------------- # # Aktivierungsprotokoll # --------------------------------------------------------------------------- # @pytest.fixture() def issued_license(bootstrapped, ls_app): from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client, type="enterprise") client.post( f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": _token(client, f"/tickets/{ticket_id}")}, follow_redirects=True, ) extension = core(ls_app) with ls_app.app_context(), extension.database.session() as conn: row = issuance.list_licenses(conn)[0] bundle = issuance.delivery_bundle( conn, str(row["license_id"]), keystore=extension.keystore ) document, client_key = lic.split_bundle(bundle) return {"document": document, "client_key": client_key, "row": row} def test_activation_round_trip_over_api(ls_client, issued_license): document = issued_license["document"] request = lic.build_client_request( "activate", document, issued_license["client_key"], hostname="kunde-01" ) response = ls_client.post("/api/v1/licenses/activate", json=request) assert response.status_code == 200, response.get_json() payload = response.get_json() lic.verify_master_response( payload, document["master_pubkey"], expected_license_id=document["license_id"], expected_fingerprint=request["fingerprint"], expected_action="activate", expected_nonce=request["nonce"], ) assert payload["status"] == "ok" def test_replayed_activation_is_rejected(ls_client, issued_license): document = issued_license["document"] request = lic.build_client_request("activate", document, issued_license["client_key"]) assert ls_client.post("/api/v1/licenses/activate", json=request).status_code == 200 replay = ls_client.post("/api/v1/licenses/activate", json=request) assert replay.status_code == 409 assert replay.get_json()["error"] == "replay" def test_forged_signature_rejected(ls_client, issued_license): document = issued_license["document"] other_private, _ = lic.generate_keypair() request = lic.build_client_request("activate", document, other_private) response = ls_client.post("/api/v1/licenses/activate", json=request) assert response.status_code == 400 assert response.get_json()["error"] == "invalid_signature" def test_activation_bound_to_fingerprint(ls_client, ls_app, issued_license): from tesm_core.extension import core document = issued_license["document"] request = lic.build_client_request("activate", document, issued_license["client_key"]) ls_client.post("/api/v1/licenses/activate", json=request) # Zweite Maschine: anderer Fingerprint, gueltige Signatur. second = lic.build_client_request("activate", document, issued_license["client_key"]) second["fingerprint"] = "b" * 64 second["signature"] = lic.sign( {k: v for k, v in second.items() if k != "signature"}, issued_license["client_key"], lic.SIG_CONTEXT_REQUEST, ) response = ls_client.post("/api/v1/licenses/activate", json=second) assert response.status_code == 409 assert response.get_json()["error"] == "already_bound_elsewhere" assert core(ls_app) is not None def test_heartbeat_requires_active_license(ls_client, issued_license): document = issued_license["document"] request = lic.build_client_request("heartbeat", document, issued_license["client_key"]) response = ls_client.post("/api/v1/licenses/heartbeat", json=request) assert response.status_code == 409 assert response.get_json()["error"] == "not_active" def test_heartbeat_after_activation_delivers_update(ls_client, ls_app, issued_license): from tesm_core.extension import core from tesm_license.services import issuance from tesm_license.services import master_key document = issued_license["document"] activate = lic.build_client_request("activate", document, issued_license["client_key"]) ls_client.post("/api/v1/licenses/activate", json=activate) # Der Anbieter erweitert die Lizenz nachtraeglich. extension = core(ls_app) with ls_app.app_context(), extension.database.session() as conn: key = master_key.ensure(extension.registry["master_key_path"]) with extension.database.transaction(conn): issuance.resign_license( conn, document["license_id"], master_private_key=key.private_key, changes={"modules": ["dhcp"]}, ) beat = lic.build_client_request("heartbeat", document, issued_license["client_key"]) response = ls_client.post("/api/v1/licenses/heartbeat", json=beat) assert response.status_code == 200 payload = response.get_json() assert payload["status"] == "ok" assert payload["license_update"]["seq"] == document["seq"] + 1 lic.verify_license(payload["license_update"]) def test_revoked_license_reported_signed(ls_client, ls_app, issued_license): from tesm_core.extension import core from tesm_license.services import issuance document = issued_license["document"] activate = lic.build_client_request("activate", document, issued_license["client_key"]) ls_client.post("/api/v1/licenses/activate", json=activate) extension = core(ls_app) with ls_app.app_context(), extension.database.session() as conn: with extension.database.transaction(conn): issuance.revoke_license(conn, document["license_id"], reason="Test") beat = lic.build_client_request("heartbeat", document, issued_license["client_key"]) response = ls_client.post("/api/v1/licenses/heartbeat", json=beat) assert response.status_code == 200 payload = response.get_json() assert payload["status"] == "revoked" # Die Antwort ist signiert -- der Client loescht erst nach eigener Pruefung. lic.verify_master_response( payload, document["master_pubkey"], expected_license_id=document["license_id"], expected_fingerprint=beat["fingerprint"], expected_action="heartbeat", expected_nonce=beat["nonce"], ) def test_offline_code_path_matches_api(bootstrapped, issued_license): """Der Offline-Weg laeuft durch dieselbe Verarbeitung wie die API.""" document = issued_license["document"] request = lic.build_client_request("activate", document, issued_license["client_key"]) code = lic.encode_code(request) response = bootstrapped.post( "/lizenzen/offline-code", data={"csrf_token": _token(bootstrapped, "/lizenzen/offline-code"), "code": code}, follow_redirects=True, ) body = response.get_data(as_text=True) assert "Antwortcode" in body start = body.index('id="response-code">') + len('id="response-code">') answer = body[start : body.index("", start)].strip() decoded = lic.decode_code(answer) lic.verify_master_response( decoded, document["master_pubkey"], expected_license_id=document["license_id"], expected_fingerprint=request["fingerprint"], expected_action="activate", expected_nonce=request["nonce"], ) assert decoded["status"] == "ok" def test_unknown_license_rejected(ls_client, issued_license): document = dict(issued_license["document"]) document["license_id"] = "gibt-es-nicht" request = lic.build_client_request("activate", document, issued_license["client_key"]) response = ls_client.post("/api/v1/licenses/activate", json=request) assert response.status_code == 404 # --------------------------------------------------------------------------- # # Self-Service und Portal # --------------------------------------------------------------------------- # def test_self_service_page_and_issue(bootstrapped, ls_app): client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client) fresh = ls_app.test_client() # ohne Anmeldung page = fresh.get(f"/lizenz/{ticket_id}") assert page.status_code == 200 assert "Lizenz erstellen" in page.get_data(as_text=True) token = _token(fresh, f"/lizenz/{ticket_id}") created = fresh.post( f"/lizenz/{ticket_id}/ausstellen", data={"csrf_token": token}, follow_redirects=True ) assert "Lizenz wurde erstellt" in created.get_data(as_text=True) def test_self_service_404_for_license_server_ticket(bootstrapped, ls_app): from tesm_core.extension import core from tesm_license.services import issuance client = bootstrapped _create_customer(client) ticket_id = _create_ticket(client) extension = core(ls_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "UPDATE license_tickets SET role='license_server' WHERE id=?", (ticket_id,) ) fresh = ls_app.test_client() assert fresh.get(f"/lizenz/{ticket_id}").status_code == 404 def test_portal_answer_is_identical_for_unknown_email(ls_client): token = _token(ls_client, "/portal/") known = ls_client.post( "/portal/", data={"csrf_token": token, "email": "gibt@es.nicht"}, follow_redirects=True ).get_data(as_text=True) assert "Falls diese E-Mail-Adresse bei uns hinterlegt ist" in known def test_portal_token_is_single_use(bootstrapped, ls_app): from tesm_core.extension import core client = bootstrapped _create_customer(client) # Token direkt anlegen (Mailversand ist im Test nicht konfiguriert). extension = core(ls_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) " "VALUES ('testtoken', 1, datetime('now'), datetime('now', '+30 minutes'))" ) fresh = ls_app.test_client() first = fresh.get("/portal/zugang/testtoken", follow_redirects=True) assert "Ihre Lizenzen" in first.get_data(as_text=True) second = ls_app.test_client() again = second.get("/portal/zugang/testtoken", follow_redirects=True) assert "ungueltig oder abgelaufen" in again.get_data(as_text=True) def test_portal_expired_token_rejected(bootstrapped, ls_app): from tesm_core.extension import core _create_customer(bootstrapped) extension = core(ls_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) " "VALUES ('alt', 1, datetime('now','-2 hours'), datetime('now','-1 hours'))" ) fresh = ls_app.test_client() response = fresh.get("/portal/zugang/alt", follow_redirects=True) assert "ungueltig oder abgelaufen" in response.get_data(as_text=True) # --------------------------------------------------------------------------- # # Ampel # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "days,expected", [(120, "green"), (91, "green"), (90, "orange"), (31, "orange"), (29, "red"), (-1, "red")], ) def test_traffic_light_boundaries(days, expected): from datetime import timedelta from tesm_license.services.issuance import display_state now = lic.utcnow() row = {"expires_at": lic.iso(now + timedelta(days=days, hours=1)), "lifetime": 0} assert display_state(row, now=now)["color"] == expected def test_lifetime_is_always_green(): from tesm_license.services.issuance import display_state state = display_state({"expires_at": "2000-01-01T00:00:00Z", "lifetime": 1}) assert state["color"] == "green" assert state["is_expired"] is False def test_unparsable_date_fails_safe(): from tesm_license.services.issuance import display_state assert display_state({"expires_at": "kaputt", "lifetime": 0})["color"] == "red" # --------------------------------------------------------------------------- # # Endpunkt: ohne ihn keine Lizenz # --------------------------------------------------------------------------- # def test_issuing_without_an_endpoint_is_refused(): """Der Endpunkt wird mitsigniert -- fehlt er, ist die Lizenz dauerhaft offline. Nachtraeglich laesst er sich nicht eintragen: er steht in der signierten Nutzlast. Eine Warnung waere zu spaet, denn der Schaden entsteht in dem Moment, in dem die Datei den Server verlaesst. """ import pytest as _pytest from tesm_license.services import issuance assert "master_endpoint" in issuance.issue_from_ticket.__code__.co_varnames source = __import__("inspect").getsource(issuance.issue_from_ticket) assert 'if not str(master_endpoint or "").strip():' in source assert "IssuanceError" in source assert _pytest is not None def test_offline_activation_has_a_navigation_entry(): """Ohne Eintrag findet niemand die Seite -- der Offline-Weg waere unbenutzbar.""" from tesm_license.navigation import NAVIGATION endpoints = set() def walk(items): for item in items: if item.endpoint: endpoints.add(item.endpoint) if item.children: walk(item.children) walk(NAVIGATION.items) assert "licenses.manual_code" in endpoints def test_revision_pending_survives_a_partial_row(): """Die Funktion sitzt in Listen, Detailseiten und im Portal. Nicht jede Abfrage waehlt jede Spalte. Eine fehlende darf keine Ausnahme werfen -- sonst faellt eine ganze Seite wegen einer Nebensaechlichkeit aus. """ from tesm_license.services.issuance import revision_pending assert revision_pending({"revision_at": "2026-01-02", "status": "active"}) is True assert revision_pending({"revision_at": None}) is False assert revision_pending({}) is False assert revision_pending({"revision_at": "2026-01-02", "status": "revoked"}) is False # Abgeholt: der Kontakt ist juenger als die Revision. assert ( revision_pending( { "revision_at": "2026-01-02T00:00:00+00:00", "status": "active", "last_heartbeat_at": "2026-01-03T00:00:00+00:00", } ) is False ) # Noch nicht abgeholt. assert ( revision_pending( { "revision_at": "2026-01-04T00:00:00+00:00", "status": "active", "last_heartbeat_at": "2026-01-03T00:00:00+00:00", } ) is True )