diff --git a/create_pod_finance.sh b/create_pod_finance.sh index 9ef0a15..096669e 100755 --- a/create_pod_finance.sh +++ b/create_pod_finance.sh @@ -212,6 +212,12 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \ echo "Role 'finance_read' is ready." # API container (runs alembic upgrade head on start via entrypoint.sh) +# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin +# password change) is a SINGLE-FILE bind mount. Unlike a directory mount, +# this follows the host file's INODE: the app must read/write it in place +# (open r+/truncate under flock - see app/services/admin.py), NEVER via +# temp-file+rename, because a rename would swap in a new inode that the +# already-running mount no longer points at. podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \ -e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \ -e FB_API_KEY \ @@ -221,6 +227,7 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \ -e FB_INBOX_DIR=/data/inbox \ -e FB_UPLOADS_DIR=/data/uploads \ -v "$DATA_DIR:/data:Z" \ + -v "$ENV_FILE:/data/.env:Z" \ "$API_IMAGE" echo "Container '$API_CTR_NAME' started (rc=$?)" diff --git a/finance/app/auth.py b/finance/app/auth.py index 098c4b3..243f90a 100644 --- a/finance/app/auth.py +++ b/finance/app/auth.py @@ -1,6 +1,8 @@ import hashlib import hmac +import re import secrets +from pathlib import Path from fastapi import HTTPException, Request from itsdangerous import BadSignature, TimestampSigner @@ -10,6 +12,48 @@ from app.config import get_settings COOKIE = "fb_session" MAX_AGE = 60 * 60 * 12 # 12 h +# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen +# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem +# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald +# services.admin.change_password() die Datei in-place neu schreibt (neue +# mtime). +_hash_cache: dict[str, tuple[float, str]] = {} + +_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$") + + +def _parse_env_file(env_file: Path) -> dict[str, str]: + """Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh + schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden + ignoriert statt einen Fehler zu werfen.""" + values: dict[str, str] = {} + for line in env_file.read_text().splitlines(): + m = _ENV_LINE_RE.match(line) + if m: + values[m.group(1)] = m.group(2) + return values + + +def current_password_hash() -> str: + """Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount, + siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount + folgt dem Inode, die Datei wird von services.admin.change_password() + in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale + Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen.""" + settings = get_settings() + env_file = settings.env_file + if env_file.exists(): + mtime = env_file.stat().st_mtime + key = str(env_file) + cached = _hash_cache.get(key) + if cached is not None and cached[0] == mtime: + return cached[1] + value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH") + if value is not None: + _hash_cache[key] = (mtime, value) + return value + return settings.gui_password_hash + def hash_password(pw: str, salt: str | None = None) -> str: salt = salt or secrets.token_hex(16) diff --git a/finance/app/config.py b/finance/app/config.py index 12fdc74..7b36a76 100644 --- a/finance/app/config.py +++ b/finance/app/config.py @@ -16,6 +16,8 @@ class Settings: uploads_dir: Path warn_threshold: Decimal horizon_days: int + env_file: Path + grafana_url: str @lru_cache @@ -31,4 +33,9 @@ def get_settings() -> Settings: uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")), warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")), horizon_days=int(e("FB_HORIZON_DAYS", "548")), + # Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen + # der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe + # 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env". + env_file=Path(e("FB_ENV_FILE", "/data/.env")), + grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"), ) diff --git a/finance/app/main.py b/finance/app/main.py index d28b058..be9be9d 100644 --- a/finance/app/main.py +++ b/finance/app/main.py @@ -10,7 +10,7 @@ from app.auth import require_auth from app.config import get_settings from app.db import get_engine from app.models.views import create_views -from app.routers import (accounts, categories, gui, imports, planning, +from app.routers import (accounts, admin, categories, gui, imports, planning, scenarios, transactions) from app.routers.gui import templates from app.version import get_version @@ -33,6 +33,7 @@ app.include_router(categories.router) app.include_router(imports.router) app.include_router(planning.router) app.include_router(scenarios.router) +app.include_router(admin.router) @app.get("/login") @@ -44,7 +45,7 @@ def login_form(request: Request): def login(username: str = Form(...), password: str = Form(...)): s = get_settings() if not (hmac.compare_digest(username, s.gui_user) - and auth.verify_password(password, s.gui_password_hash)): + and auth.verify_password(password, auth.current_password_hash())): return HTMLResponse("Login fehlgeschlagen", status_code=401) resp = RedirectResponse("/", status_code=303) resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True, diff --git a/finance/app/routers/admin.py b/finance/app/routers/admin.py new file mode 100644 index 0000000..17dc2c5 --- /dev/null +++ b/finance/app/routers/admin.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm import Session + +from app.auth import require_auth +from app.db import get_session +from app.routers.gui import gui_session, templates +from app.services.admin import apply_rules_retroactively, change_password + +router = APIRouter() + + +@router.get("/admin", dependencies=[Depends(gui_session)]) +def admin_page(request: Request): + return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None}) + + +@router.post("/admin/passwort", dependencies=[Depends(gui_session)]) +def admin_change_password( + request: Request, + alt: str = Form(...), + neu: str = Form(...), + neu2: str = Form(...), +): + # UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler + # werden inline auf derselben Seite gemeldet statt still zu verwerfen. + if len(neu) < 8: + return templates.TemplateResponse(request, "admin.html", { + "error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.", + "success": None, + }, status_code=400) + if neu != neu2: + return templates.TemplateResponse(request, "admin.html", { + "error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.", + "success": None, + }, status_code=400) + try: + change_password(alt, neu) + except (ValueError, RuntimeError) as exc: + return templates.TemplateResponse(request, "admin.html", { + "error": str(exc), + "success": None, + }, status_code=400) + return templates.TemplateResponse(request, "admin.html", { + "error": None, + "success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). " + "Bestehende Sitzungen bleiben angemeldet.", + }) + + +@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)]) +def apply_category_rules(session: Session = Depends(get_session)): + return {"categorized": apply_rules_retroactively(session)} diff --git a/finance/app/services/admin.py b/finance/app/services/admin.py new file mode 100644 index 0000000..e44e8c2 --- /dev/null +++ b/finance/app/services/admin.py @@ -0,0 +1,116 @@ +"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und +Regel-Neuanwendung (Ausbaustufe 4 Task 2). + +KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container +als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode - +die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock), +NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom +Container nicht mehr gesehen). +""" +import fcntl +import json +import urllib.error +import urllib.request +from base64 import b64encode +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth import current_password_hash, hash_password, verify_password +from app.config import get_settings +from app.models.tables import Transaction +from app.services.categorize import apply_rules + + +def _sync_grafana_password(old: str, new: str, base_url: str) -> None: + """PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem + ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit + dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem + Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei + unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana).""" + url = f"{base_url.rstrip('/')}/api/admin/users/1/password" + auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode() + body = json.dumps({"password": new}).encode() + req = urllib.request.Request( + url, data=body, method="PUT", + headers={"Authorization": auth_header, "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + if resp.status >= 400: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})." + ) + except urllib.error.HTTPError as exc: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})." + ) from exc + except urllib.error.URLError as exc: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}" + ) from exc + except (TimeoutError, OSError) as exc: + # Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau) + # kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT + # in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber + # dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen). + # Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen + # und dort als 500 statt als deutsche 400-Fehlermeldung enden. + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen: {exc}" + ) from exc + + +def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None: + """Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter + flock, truncate) - der Inode der Datei bleibt unveraendert, siehe + Modul-Docstring.""" + with open(env_file, "r+", encoding="utf-8") as f: + fcntl.flock(f, fcntl.LOCK_EX) + try: + lines = f.read().splitlines() + out = [] + for line in lines: + if line.startswith("FB_PASSWORD="): + out.append(f"FB_PASSWORD='{new_password}'") + elif line.startswith("FB_GUI_PASSWORD_HASH="): + out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'") + else: + out.append(line) + f.seek(0) + f.write("\n".join(out) + "\n") + f.truncate() + finally: + fcntl.flock(f, fcntl.LOCK_UN) + + +def change_password(old: str, new: str) -> None: + """Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend: + 1) altes Passwort gegen den aktuellen Hash verifizieren, + 2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env + anzufassen), + 3) erst danach die .env in-place neu schreiben. + """ + if not verify_password(old, current_password_hash()): + raise ValueError("Das alte Passwort ist falsch.") + + settings = get_settings() + _sync_grafana_password(old, new, settings.grafana_url) + + new_hash = hash_password(new) + _rewrite_env_file(settings.env_file, new, new_hash) + + +def apply_rules_retroactively(session: Session) -> int: + """Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle + bestaetigten, noch unkategorisierten Buchungen an und committet.""" + txs = session.execute( + select(Transaction).where( + Transaction.status == "confirmed", + Transaction.category_id.is_(None), + ) + ).scalars().all() + hits = apply_rules(session, txs) + session.commit() + return hits diff --git a/finance/app/static/style.css b/finance/app/static/style.css index 9d8c603..2d4201b 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -72,6 +72,14 @@ td.account { border-radius: 4px; } +.success { + background: #d4edda; + border: 1px solid #7bc088; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + .total-balance { font-size: 1.5rem; font-weight: bold; diff --git a/finance/app/templates/admin.html b/finance/app/templates/admin.html new file mode 100644 index 0000000..c1f9027 --- /dev/null +++ b/finance/app/templates/admin.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Admin – Finanzberatung{% endblock %} +{% block content %} +

Admin

+ +
+

Passwort ändern

+

Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.

+ {% if error %}
{{ error }}
{% endif %} + {% if success %}
{{ success }}
{% endif %} +
+ + + + +
+
+ +
+

Regeln neu anwenden

+

Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.

+ +

+
+ + +{% endblock %} diff --git a/finance/app/templates/base.html b/finance/app/templates/base.html index 0fa0915..edacb9e 100644 --- a/finance/app/templates/base.html +++ b/finance/app/templates/base.html @@ -14,6 +14,7 @@ Buchungen Salden Planung + Admin Hilfe Grafana
diff --git a/finance/tests/conftest.py b/finance/tests/conftest.py index d0a26f6..439d94b 100644 --- a/finance/tests/conftest.py +++ b/finance/tests/conftest.py @@ -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() diff --git a/finance/tests/test_admin.py b/finance/tests/test_admin.py new file mode 100644 index 0000000..14e793b --- /dev/null +++ b/finance/tests/test_admin.py @@ -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 diff --git a/finance/tests/test_auth.py b/finance/tests/test_auth.py index 875f112..52fb0ce 100644 --- a/finance/tests/test_auth.py +++ b/finance/tests/test_auth.py @@ -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) diff --git a/finance/tests/test_config.py b/finance/tests/test_config.py index 07ac514..9e2de0a 100644 --- a/finance/tests/test_config.py +++ b/finance/tests/test_config.py @@ -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()