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>
704 lines
25 KiB
Python
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
|
|
)
|