316 lines
11 KiB
Python
316 lines
11 KiB
Python
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
|
|
|
|
|
|
# --- Kategorien-Verwaltung --------------------------------------------------
|
|
|
|
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
|
|
from app.models.tables import Category
|
|
client, password = client_with_env_file
|
|
client.post("/login", data={"username": "admin", "password": password})
|
|
db.add(Category(name="Admin-Kat"))
|
|
db.commit()
|
|
r = client.get("/admin").text
|
|
assert "Kategorien" in r and "Admin-Kat" in r
|
|
assert "Neue Kategorie anlegen" in r
|
|
assert 'hx-patch="/api/categories/' in r
|
|
assert 'hx-post="/api/categories"' in r
|
|
|
|
|
|
# --- 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
|