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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
72
tests/conftest.py
Normal file
72
tests/conftest.py
Normal 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
66
tests/test_auth.py
Normal 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
|
||||
137
tests/test_benutzerverwaltung.py
Normal file
137
tests/test_benutzerverwaltung.py
Normal 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
78
tests/test_migrations.py
Normal 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"]
|
||||
96
tests/test_plugin_loader.py
Normal file
96
tests/test_plugin_loader.py
Normal 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
48
tests/test_rollen.py
Normal 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
|
||||
Reference in New Issue
Block a user