feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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=$?)"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
52
finance/app/routers/admin.py
Normal file
52
finance/app/routers/admin.py
Normal 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)}
|
||||||
116
finance/app/services/admin.py
Normal file
116
finance/app/services/admin.py
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
46
finance/app/templates/admin.html
Normal file
46
finance/app/templates/admin.html
Normal 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 %}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
300
finance/tests/test_admin.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user