TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * 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. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * 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