Phase 1: Kern mit Plugin-System, Auth/Rollen, Migrations, Plugin-Stubs

- redaktionskern (src/): schlanker Kern — App-Fabrik, Plugin-Loader
  (Entry-Points + plugins/-Verzeichnis), Migrations-Laufzeit
  (schema_migrations pro Plugin, SQLite-/Postgres-portabel),
  Auth mit Argon2id + Session-Cookies, Rollen admin/redakteur/rezensent,
  Benutzerverwaltung für Admins
- plugins/: 8 ladbare Stubs (neuheiten, dedup, planung, archiv,
  erinnerung, benachrichtigung, audit-log, export) nach Plugin-Vertrag
- Frontend: Jinja2 + Tailwind (CDN) + HTMX + Alpine.js, UI deutsch
- Tests: 28 pytest-Fälle (Loader, Lifecycle, Entry-Points, Migrations-
  Idempotenz, Login/Logout, Rollen-Zugriff, Benutzerverwaltung)
- Docker/Podman: Compose (Traefik-Labels) + Dockerfile (uv)
- README.md mit Setup-Anleitung
This commit is contained in:
Flo Hartmann
2026-08-21 18:35:09 +00:00
parent 72f016a263
commit 96a37e878b
50 changed files with 3343 additions and 0 deletions

0
tests/__init__.py Normal file
View File

72
tests/conftest.py Normal file
View File

@@ -0,0 +1,72 @@
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from redaktionskern.config import Settings
PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
ADMIN_PASSWORD = "test-admin-123"
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
database_url=f"sqlite:///{tmp_path / 'test.db'}",
session_secret="test-secret",
initial_admin_password=ADMIN_PASSWORD,
plugins_dir=PROJEKT_WURZEL / "plugins",
)
@pytest.fixture
def settings_ohne_plugins(settings) -> Settings:
"""Nur Entry-Point-Plugins, kein plugins/-Verzeichnis."""
return replace(settings, plugins_dir=None)
@pytest.fixture
def app(settings):
return create_app(settings)
@pytest.fixture
def client(app) -> TestClient:
return TestClient(app)
def melde_an(
client: TestClient,
username: str = "admin",
password: str = ADMIN_PASSWORD,
):
"""Meldet einen Benutzer an und prueft die Weiterleitung."""
antwort = client.post(
"/login", data={"username": username, "password": password},
follow_redirects=False,
)
assert antwort.status_code == 303, f"Login fehlgeschlagen fuer {username}"
return antwort
def lege_benutzer_an(app, username: str, role: str, password: str = "test-12345678"):
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
from redaktionskern.auth.models import User
from redaktionskern.auth.security import hash_password
with app.state.session_factory() as db:
db.add(
User(
username=username,
display_name=username.capitalize(),
password_hash=hash_password(password),
role=role,
active=True,
)
)
db.commit()

66
tests/test_auth.py Normal file
View File

@@ -0,0 +1,66 @@
"""Tests: Login/Logout und initialer Admin."""
from __future__ import annotations
from sqlalchemy import select
from redaktionskern.auth.models import User
def test_initialer_admin_wird_angelegt(app):
with app.state.session_factory() as db:
admin = db.scalar(select(User).where(User.username == "admin"))
assert admin is not None
assert admin.role == "admin"
assert admin.active is True
def test_login_seite_erreichbar(client):
antwort = client.get("/login")
assert antwort.status_code == 200
assert "Anmelden" in antwort.text
def test_geschuetzte_seite_leitet_anonym_zum_login(client):
antwort = client.get("/", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
def test_login_falsches_passwort(client):
antwort = client.post(
"/login",
data={"username": "admin", "password": "falsch"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "falsch" in antwort.text
def test_login_unbekannter_benutzer(client):
antwort = client.post(
"/login",
data={"username": "niemand", "password": "egal-egal-1"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "falsch" in antwort.text
def test_login_erfolg_und_dashboard(client):
from tests.conftest import melde_an
melde_an(client)
antwort = client.get("/")
assert antwort.status_code == 200
assert "Willkommen" in antwort.text
def test_logout(client):
from tests.conftest import melde_an
melde_an(client)
antwort = client.post("/logout", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
danach = client.get("/", follow_redirects=False)
assert danach.status_code == 303 # wieder anonym

View File

@@ -0,0 +1,137 @@
"""Tests: Benutzerverwaltung des Admins (anlegen, bearbeiten, Schutzzonen)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from redaktionskern.auth.models import User
from tests.conftest import melde_an
def zweiter_client(client) -> TestClient:
"""Frische Sitzung (eigene Cookies) auf derselben App."""
return TestClient(client.app)
def _liste_enthält(html: str, username: str) -> bool:
return f">{username}<" in html
def test_admin_legt_benutzer_an(app, client):
melde_an(client)
antwort = client.post(
"/admin/benutzer/neu",
data={
"username": "clara",
"display_name": "Clara Klar",
"role": "rezensent",
"password": "geheim-1234",
},
follow_redirects=False,
)
assert antwort.status_code == 303
liste = client.get("/admin/benutzer").text
assert _liste_enthält(liste, "clara")
# Der neue Benutzer kann sich anmelden.
melde_an(zweiter_client(client), "clara", "geheim-1234")
def test_doppelter_benutzername_wird_abgelehnt(client):
melde_an(client)
daten = {"username": "doppel", "display_name": "", "role": "rezensent",
"password": "geheim-1234"}
erste = client.post("/admin/benutzer/neu", data=daten, follow_redirects=False)
assert erste.status_code == 303
zweite = client.post("/admin/benutzer/neu", data=daten)
assert zweite.status_code == 400
assert "bereits vergeben" in zweite.text
def test_zu_kurzes_passwort_wird_abgelehnt(client):
melde_an(client)
antwort = client.post(
"/admin/benutzer/neu",
data={"username": "kurzer", "display_name": "", "role": "rezensent",
"password": "kurz"},
)
assert antwort.status_code == 400
assert "mindestens 8 Zeichen" in antwort.text
def test_bearbeiten_rolle_status_passwort(app, client):
melde_an(client)
client.post(
"/admin/benutzer/neu",
data={"username": "benno", "display_name": "Benno B.", "role": "rezensent",
"password": "geheim-1234"},
follow_redirects=False,
)
with app.state.session_factory() as db:
benno = db.scalar(select(User).where(User.username == "benno"))
benno_id = benno.id
antwort = client.post(
f"/admin/benutzer/{benno_id}",
data={"display_name": "Benno Bearbeitet", "role": "redakteur",
"active": "on", "password": ""},
follow_redirects=False,
)
assert antwort.status_code == 303
with app.state.session_factory() as db:
benno = db.get(User, benno_id)
assert benno.role == "redakteur"
assert benno.display_name == "Benno Bearbeitet"
assert benno.active is True
def test_admin_kann_sich_selbst_nicht_entmachten(client, app):
"""Eigene Rolle/Status sind geschützt — kein Aussperren möglich."""
with app.state.session_factory() as db:
admin = db.scalar(select(User).where(User.username == "admin"))
admin_id = admin.id
melde_an(client)
antwort = client.post(
f"/admin/benutzer/{admin_id}",
data={"display_name": "Administration", "role": "rezensent", "active": ""},
)
assert antwort.status_code == 400
assert "nicht ändern" in antwort.text
with app.state.session_factory() as db:
admin = db.get(User, admin_id)
assert admin.role == "admin"
assert admin.active is True
def test_neuer_benutzer_kann_sich_anmelden_nach_deaktivierung_nicht_mehr(app, client):
melde_an(client)
client.post(
"/admin/benutzer/neu",
data={"username": "dora", "display_name": "Dora D.", "role": "rezensent",
"password": "geheim-1234"},
follow_redirects=False,
)
with app.state.session_factory() as db:
dora = db.scalar(select(User).where(User.username == "dora"))
dora_id = dora.id
client.post(
f"/admin/benutzer/{dora_id}",
data={"display_name": "Dora D.", "role": "rezensent", "active": "",
"password": ""},
follow_redirects=False,
)
zweiter = zweiter_client(client)
antwort = zweiter.post(
"/login",
data={"username": "dora", "password": "geheim-1234"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "deaktiviert" in antwort.text

78
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests: Migrations-Laufzeit (Kern-Tabelle, Idempotenz, Plugin-Migration)."""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import text
from redaktionskern.config import Settings
def test_kern_tabellen_existieren(app):
with app.state.engine.connect() as conn:
zeilen = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
namen = {zeile[0] for zeile in zeilen}
assert "users" in namen
assert "schema_migrations" in namen
def test_migration_wird_protokolliert(app):
with app.state.engine.connect() as conn:
zeilen = conn.execute(
text("SELECT plugin, version FROM schema_migrations")
).fetchall()
protokoll = {(p, v) for p, v in zeilen}
assert ("core", "0001_benutzer") in protokoll
def test_plugin_migration_idempotent(tmp_path):
"""Ein Plugin mit eigener Migration; zweiter Start darf nichts doppelt tun."""
plugin_ordner = tmp_path / "plugins" / "testmig"
plugin_ordner.mkdir(parents=True)
(plugin_ordner / "__init__.py").write_text(
"""
from sqlalchemy import text
from redaktionskern.contracts import BasePlugin, Migration
def _tabelle(conn):
conn.execute(text(
"CREATE TABLE IF NOT EXISTS testplugin_dings (id INTEGER PRIMARY KEY)"
))
class TestMigPlugin(BasePlugin):
name = "testmig"
title = "Testmigration"
def migrations(self):
return [Migration(version="0001_tabelle", up=_tabelle)]
plugin = TestMigPlugin()
""",
encoding="utf-8",
)
settings = Settings(
database_url=f"sqlite:///{tmp_path / 'mig.db'}",
session_secret="s",
initial_admin_password="x",
plugins_dir=tmp_path / "plugins",
)
from redaktionskern.app import create_app
create_app(settings)
app2 = create_app(settings) # zweiter Lauf: darf nicht fehlschlagen
with app2.state.engine.connect() as conn:
tabellen = {
zeile[0]
for zeile in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
}
protokoll = conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='testmig'")
).fetchall()
assert "testplugin_dings" in tabellen
assert [z[0] for z in protokoll] == ["0001_tabelle"]

View File

@@ -0,0 +1,96 @@
"""Tests: Plugin-Loader laedt alle Stubs, Lifecycle-Hooks, Entry-Points."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from redaktionskern.contracts import BasePlugin, NavEntry
from redaktionskern.plugin_loader import PluginError, PluginRegistry
ERWARTETE_STUBS = {
"neuheiten",
"dedup",
"planung",
"archiv",
"erinnerung",
"benachrichtigung",
"audit-log",
"export",
}
def test_alle_stubs_werden_geladen(app):
namen = {p.name for p in app.state.registry.all()}
assert ERWARTETE_STUBS <= namen
def test_on_load_wurde_aufgerufen(app):
for plugin in app.state.registry.all():
if plugin.name in ERWARTETE_STUBS:
assert plugin.loaded, f"Plugin {plugin.name} ist nicht geladen."
assert plugin.context is not None
def test_stubs_haben_navigation(app):
for name in ERWARTETE_STUBS:
plugin = app.state.registry.get(name)
assert len(plugin.navigation()) == 1
def test_plugin_seiten_erreichbar(client):
from tests.conftest import melde_an
melde_an(client)
for name in sorted(ERWARTETE_STUBS):
antwort = client.get(f"/{name}")
assert antwort.status_code == 200, name
assert "ist geladen" in antwort.text
def test_on_unload_beim_herunterfahren(app):
plugin = app.state.registry.get("neuheiten")
with TestClient(app):
assert plugin.loaded
assert not plugin.loaded
def test_doppelte_plugin_namen_abgelehnt():
class A(BasePlugin):
name = "a"
registry = PluginRegistry()
registry.register(A())
with pytest.raises(PluginError):
registry.register(A())
def test_entry_point_plugins_werden_geladen(monkeypatch, settings_ohne_plugins):
"""Entry-Point-Erkennung via importlib.metadata (hier simuliert)."""
import redaktionskern.plugin_loader as loader
externes_plugin = loader._als_plugin(ExternesPlugin())
class FakeEntryPoint:
name = "extern"
def load(self):
return externes_plugin
monkeypatch.setattr(loader, "entry_points", lambda group: [FakeEntryPoint()])
from redaktionskern.app import create_app
app = create_app(settings_ohne_plugins)
geladen = app.state.registry.get("extern")
assert geladen is externes_plugin
assert geladen.loaded
class ExternesPlugin(BasePlugin):
"""Simuliert ein als Paket installiertes Plugin (Entry-Point)."""
name = "extern"
title = "Externes Plugin"
description = "Test-Plugin via Entry-Point."
def navigation(self) -> list[NavEntry]:
return []

48
tests/test_rollen.py Normal file
View File

@@ -0,0 +1,48 @@
"""Tests: Rollen-Zugriff (admin/redakteur vs. rezensent, anonym)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import lege_benutzer_an, melde_an
def test_rezensent_kein_admin_bereich(app, client):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
assert client.get("/admin/benutzer").status_code == 403
assert client.get("/").status_code == 200 # Dashboard erlaubt
def test_redakteur_kein_admin_bereich(app, client):
lege_benutzer_an(app, "red1", "redakteur")
melde_an(client, "red1", "test-12345678")
assert client.get("/admin/benutzer").status_code == 403
assert client.get("/").status_code == 200
def test_admin_hat_zugriff_auf_admin_bereich(client):
melde_an(client)
assert client.get("/admin/benutzer").status_code == 200
def test_anonym_wird_vom_admin_bereich_umgeleitet(client):
antwort = client.get("/admin/benutzer", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
def test_deaktivierter_benutzer_wird_rausgeworfen(app, client):
"""Sitzung eines deaktivierten Kontos verliert den Zugriff sofort."""
lege_benutzer_an(app, "kurz", "rezensent")
melde_an(client, "kurz", "test-12345678")
assert client.get("/").status_code == 200
from redaktionskern.auth.models import User
from sqlalchemy import select
with app.state.session_factory() as db:
benutzer = db.scalar(select(User).where(User.username == "kurz"))
benutzer.active = False
db.commit()
assert client.get("/", follow_redirects=False).status_code == 303