feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,3 +47,47 @@ def client(db, monkeypatch):
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_file(tmp_path):
|
||||
"""Erzeugt eine .env-Datei im create_pod_finance.sh-Format (single-quoted
|
||||
KEY='value') mit einem initialen Passwort+Hash. Liefert (Pfad, Passwort)
|
||||
fuer Tests der Admin-Passwortaenderung (Ausbaustufe 4 Task 2), die die
|
||||
Laufzeit-.env-Lesung/-Schreibung ueber FB_ENV_FILE gegen eine tmp_path
|
||||
statt der echten Container-.env pruefen."""
|
||||
from app.auth import hash_password
|
||||
path = tmp_path / ".env"
|
||||
password = "geheim123"
|
||||
path.write_text(
|
||||
f"FB_PASSWORD='{password}'\n"
|
||||
f"FB_GUI_PASSWORD_HASH='{hash_password(password)}'\n"
|
||||
)
|
||||
return path, password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_env_file(db, env_file, monkeypatch):
|
||||
"""Wie `client`, aber der GUI-Passwort-Hash kommt NICHT aus der
|
||||
FB_GUI_PASSWORD_HASH-Umgebungsvariable, sondern wird zur Laufzeit aus der
|
||||
ueber FB_ENV_FILE referenzierten Datei gelesen (der Pfad, den
|
||||
change_password() in-place ueberschreibt)."""
|
||||
path, _password = env_file
|
||||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||||
monkeypatch.delenv("FB_GUI_PASSWORD_HASH", raising=False)
|
||||
monkeypatch.setenv("FB_ENV_FILE", str(path))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.auth import _hash_cache
|
||||
_hash_cache.clear()
|
||||
from app.main import app
|
||||
|
||||
def _override_get_session():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
with TestClient(app) as c:
|
||||
yield c, env_file[1]
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
_hash_cache.clear()
|
||||
|
||||
300
finance/tests/test_admin.py
Normal file
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):
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user