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>
212 lines
7.1 KiB
Python
212 lines
7.1 KiB
Python
"""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
|