Files
tesm-license/tests/test_license_server.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

704 lines
25 KiB
Python

"""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("</pre>", 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
)