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>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Gemeinsame Testfixtures.
|
||||
|
||||
Jeder Test bekommt eine frische Instanz in einem temporaeren Verzeichnis --
|
||||
eigene Datenbank, eigene Schluessel, eigenes Protokollverzeichnis. Nichts
|
||||
beruehrt eine echte Installation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def instance_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
base = tmp_path / "instance"
|
||||
monkeypatch.setenv("TESM_BASE_DIR", str(base))
|
||||
monkeypatch.setenv("TESM_LICENSE_BASE_DIR", str(tmp_path / "instance-license"))
|
||||
# Tests laufen ueber http:// -- ein Secure-Cookie wuerde nie gesetzt.
|
||||
monkeypatch.setenv("TESM_COOKIE_SECURE", "0")
|
||||
monkeypatch.setenv("TESM_LICENSE_COOKIE_SECURE", "0")
|
||||
monkeypatch.setenv("TESM_DEBUG", "0")
|
||||
monkeypatch.setenv("TESM_LICENSE_DEBUG", "0")
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def core_app(instance_env: Path) -> Iterator[Any]:
|
||||
"""Minimale App auf Basis von ``tesm_core`` -- ohne Fachlogik der beiden Apps."""
|
||||
from flask import render_template_string
|
||||
|
||||
from tesm_core import load_core_config
|
||||
from tesm_core.app_factory import create_base_app
|
||||
from tesm_core.auth.models import login_required
|
||||
from tesm_core.nav import NavItem, NavTree
|
||||
from tesm_core.rbac.common import administration_area, observability_area
|
||||
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
||||
|
||||
demo = Area(
|
||||
key="demo",
|
||||
label="Demo",
|
||||
icon="grid",
|
||||
resources=(
|
||||
Resource(
|
||||
key="widgets",
|
||||
label="Widgets",
|
||||
actions=(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.SECRETS),
|
||||
),
|
||||
),
|
||||
)
|
||||
tree = PermissionTree([demo, administration_area(), observability_area()])
|
||||
nav = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Start", icon="grid", endpoint="index"),
|
||||
NavItem(
|
||||
key="widgets", label="Widgets", icon="cpu", endpoint="widgets",
|
||||
permissions=("widgets.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
children=(
|
||||
NavItem(
|
||||
key="users", label="Benutzer", icon="users", endpoint="admin.users",
|
||||
permissions=("users.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups", label="Gruppen", icon="shield-users", endpoint="admin.groups",
|
||||
permissions=("groups.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview",
|
||||
permissions=("trash.view",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics", label="Diagnose", icon="info",
|
||||
endpoint="diagnostics.overview", permissions=("diagnostics.view",),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
presets = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Nur lesen",
|
||||
grants=(("widgets", (Action.VIEW,)),),
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
|
||||
config = load_core_config(
|
||||
app_key="tesm", app_name="TESM Test", env_prefix="TESM_", default_base=instance_env
|
||||
)
|
||||
app = create_base_app(
|
||||
config=config,
|
||||
permissions=tree,
|
||||
nav=nav,
|
||||
role_presets=presets,
|
||||
app_short="TT",
|
||||
app_edition="Test",
|
||||
enable_license=False,
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def index(): # type: ignore[no-untyped-def]
|
||||
return render_template_string(
|
||||
'{% extends "tesm_core/base.html" %}{% block page_title %}Start{% endblock %}'
|
||||
"{% block content %}<p>Start</p>{% endblock %}"
|
||||
)
|
||||
|
||||
@app.get("/widgets")
|
||||
@login_required
|
||||
def widgets(): # type: ignore[no-untyped-def]
|
||||
return render_template_string(
|
||||
'{% extends "tesm_core/base.html" %}{% block page_title %}Widgets{% endblock %}'
|
||||
"{% block content %}<p>Widgets</p>{% endblock %}"
|
||||
)
|
||||
|
||||
app.config.update(TESTING=True)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(core_app: Any) -> Any:
|
||||
return core_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_user(core_app: Any):
|
||||
"""Erzeugt ein Konto direkt in der Datenbank."""
|
||||
|
||||
def factory(
|
||||
username: str = "tester",
|
||||
password: str = "Sicher-Passwort-2026",
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
permissions: tuple[str, ...] = (),
|
||||
is_locked: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.security import passwords
|
||||
|
||||
extension = core(core_app)
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,'local',datetime('now'),datetime('now'))",
|
||||
(username, passwords.hash_password(password), int(is_admin), int(is_locked)),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
if permissions:
|
||||
group = conn.execute(
|
||||
"INSERT INTO groups (name, description, created_at, updated_at) "
|
||||
"VALUES (?,'Testgruppe',datetime('now'),datetime('now'))",
|
||||
(f"grp-{username}",),
|
||||
)
|
||||
group_id = int(group.lastrowid or 0)
|
||||
conn.executemany(
|
||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, p) for p in extension.permissions.sanitize(permissions)],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id)
|
||||
)
|
||||
row = Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
|
||||
assert row is not None
|
||||
return {**row, "password": password}
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def login(client: Any):
|
||||
def do_login(username: str, password: str) -> Any:
|
||||
return client.post(
|
||||
"/login",
|
||||
data={"username": username, "password": password, "csrf_token": _token(client)},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
return do_login
|
||||
|
||||
|
||||
def _token(client: Any) -> str:
|
||||
"""Holt ein CSRF-Token aus der Anmeldeseite."""
|
||||
page = client.get("/login")
|
||||
body = page.get_data(as_text=True)
|
||||
marker = 'name="csrf_token" value="'
|
||||
start = body.index(marker) + len(marker)
|
||||
return body[start : body.index('"', start)]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def csrf_token(client: Any):
|
||||
def get(path: str = "/login") -> str:
|
||||
page = client.get(path)
|
||||
body = page.get_data(as_text=True)
|
||||
marker = 'name="csrf_token" value="'
|
||||
if marker not in body:
|
||||
marker = 'data-csrf="'
|
||||
start = body.index(marker) + len(marker)
|
||||
return body[start : body.index('"', start)]
|
||||
|
||||
return get
|
||||
Reference in New Issue
Block a user