feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 08:24:33 +02:00
parent 74684c7e6c
commit f5fa3e45e8
13 changed files with 690 additions and 2 deletions

View File

@@ -47,3 +47,47 @@ def client(db, monkeypatch):
yield c
app.dependency_overrides.clear()
get_settings.cache_clear()
@pytest.fixture
def env_file(tmp_path):
"""Erzeugt eine .env-Datei im create_pod_finance.sh-Format (single-quoted
KEY='value') mit einem initialen Passwort+Hash. Liefert (Pfad, Passwort)
fuer Tests der Admin-Passwortaenderung (Ausbaustufe 4 Task 2), die die
Laufzeit-.env-Lesung/-Schreibung ueber FB_ENV_FILE gegen eine tmp_path
statt der echten Container-.env pruefen."""
from app.auth import hash_password
path = tmp_path / ".env"
password = "geheim123"
path.write_text(
f"FB_PASSWORD='{password}'\n"
f"FB_GUI_PASSWORD_HASH='{hash_password(password)}'\n"
)
return path, password
@pytest.fixture
def client_with_env_file(db, env_file, monkeypatch):
"""Wie `client`, aber der GUI-Passwort-Hash kommt NICHT aus der
FB_GUI_PASSWORD_HASH-Umgebungsvariable, sondern wird zur Laufzeit aus der
ueber FB_ENV_FILE referenzierten Datei gelesen (der Pfad, den
change_password() in-place ueberschreibt)."""
path, _password = env_file
monkeypatch.setenv("FB_API_KEY", "test-key")
monkeypatch.delenv("FB_GUI_PASSWORD_HASH", raising=False)
monkeypatch.setenv("FB_ENV_FILE", str(path))
from app.config import get_settings
get_settings.cache_clear()
from app.auth import _hash_cache
_hash_cache.clear()
from app.main import app
def _override_get_session():
yield db
app.dependency_overrides[get_session] = _override_get_session
with TestClient(app) as c:
yield c, env_file[1]
app.dependency_overrides.clear()
get_settings.cache_clear()
_hash_cache.clear()

300
finance/tests/test_admin.py Normal file
View File

@@ -0,0 +1,300 @@
import os
from datetime import date
from decimal import Decimal
import pytest
import app.services.admin as admin_service
from app.services.admin import apply_rules_retroactively, change_password
from app.models.tables import Account, Category, CategoryRule, Transaction
# --- change_password(): reine Service-Tests (kein HTTP), gegen die
# FB_ENV_FILE-Datei aus client_with_env_file -----------------------------
def test_change_password_wrong_old_raises_without_touching_grafana_or_env(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
calls = []
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: calls.append(a))
with pytest.raises(ValueError):
change_password("falsches-passwort", "neuesPasswort123")
assert calls == [] # Grafana darf bei falschem alten Passwort nie aufgerufen werden
assert path.read_text() == before
def test_change_password_calls_grafana_before_writing_env(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
order = []
def fake_sync(old, new, base_url):
order.append("grafana")
assert path.read_text() == before # .env noch unveraendert an diesem Punkt
before = path.read_text()
monkeypatch.setattr(admin_service, "_sync_grafana_password", fake_sync)
change_password(password, "neuesPasswort123")
order.append("env-written")
assert order == ["grafana", "env-written"]
assert "FB_PASSWORD='neuesPasswort123'" in path.read_text()
def test_change_password_grafana_failure_leaves_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def fail(*a, **kw):
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
with pytest.raises(RuntimeError):
change_password(password, "neuesPasswort123")
assert path.read_text() == before
def test_change_password_success_rewrites_env_in_place_preserving_inode(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
inode_before = os.stat(path).st_ino
change_password(password, "neuesPasswort123")
inode_after = os.stat(path).st_ino
assert inode_before == inode_after # KRITISCH: kein rename, gleicher Inode
content = path.read_text()
assert "FB_PASSWORD='neuesPasswort123'" in content
assert "FB_GUI_PASSWORD_HASH='" in content
assert f"FB_GUI_PASSWORD_HASH='{password}'" not in content
def test_change_password_new_password_logs_in_old_does_not(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
change_password(password, "neuesPasswort123")
r = client.post("/login", data={"username": "admin", "password": "neuesPasswort123"},
follow_redirects=False)
assert r.status_code == 303
client.cookies.clear()
r2 = client.post("/login", data={"username": "admin", "password": password},
follow_redirects=False)
assert r2.status_code == 401
# --- /admin GUI-Route: Auth-Gate + Formular-Fehlerpfade -------------------
def test_admin_page_requires_gui_session(client_with_env_file):
client, _password = client_with_env_file
r = client.get("/admin", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
def test_admin_page_reachable_after_login(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.get("/admin")
assert r.status_code == 200
assert "Passwort ändern" in r.text
assert "Regeln neu anwenden" in r.text
def test_admin_passwort_wrong_old_returns_400_with_message(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": "falsch", "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "falsch" in r.text.lower()
def test_admin_passwort_too_short_returns_400(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "kurz1", "neu2": "kurz1",
})
assert r.status_code == 400
assert "8 Zeichen" in r.text
def test_admin_passwort_mismatch_returns_400(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "andersPasswort123",
})
assert r.status_code == 400
assert "stimmt nicht" in r.text.lower() or "wiederholung" in r.text.lower()
def test_admin_passwort_grafana_failure_returns_400_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def fail(*a, **kw):
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "grafana" in r.text.lower()
assert path.read_text() == before
def test_admin_passwort_grafana_read_timeout_returns_400_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
"""F1 (Fable-Reject): ein Hang in der Grafana-Lesephase wirft ein rohes
TimeoutError statt eines urllib.error.URLError. Ohne expliziten Fang in
_sync_grafana_password schlaegt das bis in den Router durch (500 statt
deutscher 400-Fehlermeldung) - siehe app/services/admin.py."""
client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def hang(request, timeout=None):
raise TimeoutError("timed out")
monkeypatch.setattr("app.services.admin.urllib.request.urlopen", hang)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "grafana" in r.text.lower()
assert path.read_text() == before
def test_admin_passwort_success_returns_200_with_confirmation(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
calls = []
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: calls.append(a))
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 200
assert "erfolgreich" in r.text.lower()
assert calls # Grafana wurde tatsaechlich aufgerufen
# Session-Cookie bleibt nach der Aenderung gueltig (Secret unveraendert)
r2 = client.get("/admin")
assert r2.status_code == 200
# --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
account = Account(bank="Test", iban="DE02120300000000202099", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx_confirmed_uncategorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
tx_draft = Transaction(
account_id=account.id, booking_date=date(2026, 1, 6),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="draft", dedup_hash="h2",
)
tx_already_categorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 7),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="confirmed", category_id=category.id, dedup_hash="h3",
)
db.add_all([tx_confirmed_uncategorized, tx_draft, tx_already_categorized])
db.commit()
hits = apply_rules_retroactively(db)
assert hits == 1
db.refresh(tx_confirmed_uncategorized)
db.refresh(tx_draft)
assert tx_confirmed_uncategorized.category_id == category.id
assert tx_draft.category_id is None # Entwuerfe bleiben unangetastet
def test_apply_category_rules_endpoint_requires_auth(client):
assert client.post("/api/category-rules/apply").status_code == 401
def test_apply_category_rules_endpoint_categorizes_and_returns_count(client, db):
account = Account(bank="Test", iban="DE02120300000000202098", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
db.add(tx)
db.commit()
r = client.post("/api/category-rules/apply",
headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200
assert r.json() == {"categorized": 1}
db.refresh(tx)
assert tx.category_id == category.id

View File

@@ -28,3 +28,35 @@ def test_logout_flow(client):
def test_tampered_session_cookie_rejected(client):
client.cookies.set("fb_session", "gui.invalid-signature")
assert client.get("/api/accounts").status_code == 401
def test_current_password_hash_falls_back_to_env_when_no_env_file(client, monkeypatch):
# `client`-Fixture setzt FB_GUI_PASSWORD_HASH direkt und keine FB_ENV_FILE,
# der Default-Pfad /data/.env existiert im Testlauf nicht -> Fallback.
from app.auth import current_password_hash
from app.config import get_settings
assert current_password_hash() == get_settings().gui_password_hash
def test_current_password_hash_reads_from_env_file_when_present(
client_with_env_file,
):
from app.auth import current_password_hash, verify_password
_client, password = client_with_env_file
assert verify_password(password, current_password_hash())
def test_current_password_hash_cache_invalidates_on_file_change(
client_with_env_file, env_file,
):
from app.auth import current_password_hash, hash_password, verify_password
path, password = env_file
first = current_password_hash()
assert verify_password(password, first)
new_hash = hash_password("andereswort999")
path.write_text(f"FB_PASSWORD='andereswort999'\nFB_GUI_PASSWORD_HASH='{new_hash}'\n")
second = current_password_hash()
assert second == new_hash
assert verify_password("andereswort999", second)

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from app.config import get_settings
@@ -5,3 +7,31 @@ def test_defaults():
s = get_settings()
assert s.horizon_days == 548
assert s.gui_user == "admin"
def test_env_file_defaults_to_container_mount_path(monkeypatch):
monkeypatch.delenv("FB_ENV_FILE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().env_file == Path("/data/.env")
finally:
get_settings.cache_clear()
def test_env_file_overridable_via_env_var(monkeypatch, tmp_path):
custom = tmp_path / "custom.env"
monkeypatch.setenv("FB_ENV_FILE", str(custom))
get_settings.cache_clear()
try:
assert get_settings().env_file == custom
finally:
get_settings.cache_clear()
def test_grafana_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_url == "http://localhost:3000"
finally:
get_settings.cache_clear()