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

@@ -212,6 +212,12 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
echo "Role 'finance_read' is ready." echo "Role 'finance_read' is ready."
# API container (runs alembic upgrade head on start via entrypoint.sh) # 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" \ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \ -e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
-e FB_API_KEY \ -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_INBOX_DIR=/data/inbox \
-e FB_UPLOADS_DIR=/data/uploads \ -e FB_UPLOADS_DIR=/data/uploads \
-v "$DATA_DIR:/data:Z" \ -v "$DATA_DIR:/data:Z" \
-v "$ENV_FILE:/data/.env:Z" \
"$API_IMAGE" "$API_IMAGE"
echo "Container '$API_CTR_NAME' started (rc=$?)" echo "Container '$API_CTR_NAME' started (rc=$?)"

View File

@@ -1,6 +1,8 @@
import hashlib import hashlib
import hmac import hmac
import re
import secrets import secrets
from pathlib import Path
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from itsdangerous import BadSignature, TimestampSigner from itsdangerous import BadSignature, TimestampSigner
@@ -10,6 +12,48 @@ from app.config import get_settings
COOKIE = "fb_session" COOKIE = "fb_session"
MAX_AGE = 60 * 60 * 12 # 12 h 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: def hash_password(pw: str, salt: str | None = None) -> str:
salt = salt or secrets.token_hex(16) salt = salt or secrets.token_hex(16)

View File

@@ -16,6 +16,8 @@ class Settings:
uploads_dir: Path uploads_dir: Path
warn_threshold: Decimal warn_threshold: Decimal
horizon_days: int horizon_days: int
env_file: Path
grafana_url: str
@lru_cache @lru_cache
@@ -31,4 +33,9 @@ def get_settings() -> Settings:
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")), uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")), warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
horizon_days=int(e("FB_HORIZON_DAYS", "548")), 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"),
) )

View File

@@ -10,7 +10,7 @@ from app.auth import require_auth
from app.config import get_settings from app.config import get_settings
from app.db import get_engine from app.db import get_engine
from app.models.views import create_views 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) scenarios, transactions)
from app.routers.gui import templates from app.routers.gui import templates
from app.version import get_version from app.version import get_version
@@ -33,6 +33,7 @@ app.include_router(categories.router)
app.include_router(imports.router) app.include_router(imports.router)
app.include_router(planning.router) app.include_router(planning.router)
app.include_router(scenarios.router) app.include_router(scenarios.router)
app.include_router(admin.router)
@app.get("/login") @app.get("/login")
@@ -44,7 +45,7 @@ def login_form(request: Request):
def login(username: str = Form(...), password: str = Form(...)): def login(username: str = Form(...), password: str = Form(...)):
s = get_settings() s = get_settings()
if not (hmac.compare_digest(username, s.gui_user) 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) return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303) resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True, resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,

View File

@@ -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)}

View File

@@ -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

View File

@@ -72,6 +72,14 @@ td.account {
border-radius: 4px; border-radius: 4px;
} }
.success {
background: #d4edda;
border: 1px solid #7bc088;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.total-balance { .total-balance {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Admin Finanzberatung{% endblock %}
{% block content %}
<h1>Admin</h1>
<section class="admin-section">
<h2>Passwort ändern</h2>
<p class="muted">Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.</p>
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
{% if success %}<div class="success">{{ success }}</div>{% endif %}
<form method="post" action="/admin/passwort">
<label>Altes Passwort
<input type="password" name="alt" required>
</label>
<label>Neues Passwort (mind. 8 Zeichen)
<input type="password" name="neu" minlength="8" required>
</label>
<label>Neues Passwort wiederholen
<input type="password" name="neu2" minlength="8" required>
</label>
<button type="submit">Passwort ändern</button>
</form>
</section>
<section class="admin-section">
<h2>Regeln neu anwenden</h2>
<p class="muted">Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.</p>
<button type="button" hx-post="/api/category-rules/apply" hx-swap="none"
hx-on::after-request="handleApplyRulesResult(event)">
Regeln neu anwenden
</button>
<p id="apply-rules-result"></p>
</section>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');
if (event.detail.successful) {
var data = JSON.parse(event.detail.xhr.responseText);
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
} else {
result.textContent = 'Fehler beim Anwenden der Regeln.';
}
}
</script>
{% endblock %}

View File

@@ -14,6 +14,7 @@
<a href="/buchungen">Buchungen</a> <a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a> <a href="/salden">Salden</a>
<a href="/planung">Planung</a> <a href="/planung">Planung</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a> <a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout"> <form method="post" action="/logout">

View File

@@ -47,3 +47,47 @@ def client(db, monkeypatch):
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
get_settings.cache_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): def test_tampered_session_cookie_rejected(client):
client.cookies.set("fb_session", "gui.invalid-signature") client.cookies.set("fb_session", "gui.invalid-signature")
assert client.get("/api/accounts").status_code == 401 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 from app.config import get_settings
@@ -5,3 +7,31 @@ def test_defaults():
s = get_settings() s = get_settings()
assert s.horizon_days == 548 assert s.horizon_days == 548
assert s.gui_user == "admin" 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()