chore: initialize public repository
CI / checks (push) Has been cancelled

This commit is contained in:
maddin
2026-03-22 12:57:09 +00:00
commit 6fbd1bb3c2
142 changed files with 19826 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
import sys
import pytest
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.config import Settings
from app.main import create_app
def make_settings(db_url: str) -> Settings:
return Settings(
APP_ENV="test",
DB_URL=db_url,
SESSION_SECRET="test-secret",
COOKIE_SECURE=False,
COOKIE_SAMESITE="lax",
LOGIN_RATE_LIMIT_ATTEMPTS=5,
LOGIN_RATE_LIMIT_WINDOW_MINUTES=15,
)
@pytest.fixture()
def app(tmp_path):
db_path = tmp_path / "test.db"
settings = make_settings(f"sqlite:///{db_path}")
return create_app(settings)
+196
View File
@@ -0,0 +1,196 @@
from fastapi.testclient import TestClient
import pyotp
from app.config import Settings
from app.main import create_app
def _build_settings(db_url: str, *, bootstrap_admin_email: str | None = None) -> Settings:
return Settings(
APP_ENV="test",
DB_URL=db_url,
SESSION_SECRET="test-secret",
COOKIE_SECURE=False,
COOKIE_SAMESITE="lax",
LOGIN_RATE_LIMIT_ATTEMPTS=5,
LOGIN_RATE_LIMIT_WINDOW_MINUTES=15,
BOOTSTRAP_ADMIN_EMAIL=bootstrap_admin_email,
)
def test_bootstrap_admin_can_manage_users(tmp_path):
db_path = tmp_path / "admin.db"
app = create_app(_build_settings(f"sqlite:///{db_path}", bootstrap_admin_email="admin@example.com"))
with TestClient(app) as admin_client, TestClient(app) as user_client:
admin_register = admin_client.post(
"/auth/register",
json={"email": "admin@example.com", "password": "verystrongPass123"},
)
assert admin_register.status_code == 200
admin_payload = admin_register.json()
assert admin_payload["role"] == "admin"
admin_csrf = admin_payload["csrf_token"]
user_register = user_client.post(
"/auth/register",
json={"email": "normal@example.com", "password": "verystrongPass123"},
)
assert user_register.status_code == 200
user_payload = user_register.json()
user_id = user_payload["id"]
user_csrf = user_payload["csrf_token"]
forbidden = user_client.post(
"/settings/admin/users/" + user_id,
data={"csrf_token": user_csrf, "role": "admin", "is_active": "on"},
follow_redirects=False,
)
assert forbidden.status_code == 403
updated = admin_client.post(
"/settings/admin/users/" + user_id,
data={"csrf_token": admin_csrf, "role": "admin", "is_active": "on"},
follow_redirects=False,
)
assert updated.status_code == 303
assert updated.headers["location"].startswith("/settings?tab=admin&msg=admin_user_updated")
me_after = user_client.get("/me")
assert me_after.status_code == 200
assert me_after.json()["role"] == "admin"
def test_special_case_email_no_longer_becomes_admin(app):
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "special-case@example.net", "password": "verystrongPass123"},
)
assert register.status_code == 200
assert register.json()["role"] == "user"
def test_totp_mfa_login_flow(app):
password = "verystrongPass123"
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "mfa-user@example.com", "password": password},
)
assert register.status_code == 200
csrf_token = register.json()["csrf_token"]
setup_start = client.post(
"/settings/mfa",
data={
"csrf_token": csrf_token,
"mfa_method": "totp",
"current_password": password,
"setup_code": "",
},
)
assert setup_start.status_code == 200
assert "TOTP Secret" in setup_start.text
assert "TOTP-Einrichtung läuft" in setup_start.text
assert "Aktive Methode: <strong>Keine 2FA</strong>" not in setup_start.text
assert 'option value="totp" selected' in setup_start.text
assert 'option value="none" selected' not in setup_start.text
marker = 'name="setup_code"'
assert marker in setup_start.text
secret_prefix = 'TOTP Secret'
assert secret_prefix in setup_start.text
value_marker = 'readonly'
assert value_marker in setup_start.text
# Extract setup secret from rendered readonly input field.
snippet_start = setup_start.text.find("TOTP Secret")
secret_value_start = setup_start.text.find("value=\"", snippet_start) + len("value=\"")
secret_value_end = setup_start.text.find("\"", secret_value_start)
setup_secret = setup_start.text[secret_value_start:secret_value_end]
assert setup_secret
setup_code = pyotp.TOTP(setup_secret).now()
setup_finish = client.post(
"/settings/mfa",
data={
"csrf_token": csrf_token,
"mfa_method": "totp",
"current_password": password,
"setup_code": setup_code,
},
follow_redirects=False,
)
assert setup_finish.status_code == 303
assert setup_finish.headers["location"].startswith("/settings?msg=mfa_updated")
settings_after = client.get("/settings")
assert settings_after.status_code == 200
assert "Status:" in settings_after.text
assert "Authenticator-App (TOTP)" in settings_after.text
assert "TOTP-Einrichtung läuft" not in settings_after.text
def test_totp_setup_form_keeps_totp_selected_until_confirmation(app):
password = "verystrongPass123"
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "mfa-user-2@example.com", "password": password},
)
assert register.status_code == 200
csrf_token = register.json()["csrf_token"]
setup_start = client.post(
"/settings/mfa",
data={
"csrf_token": csrf_token,
"mfa_method": "totp",
"current_password": password,
"setup_code": "",
},
)
assert setup_start.status_code == 200
assert 'option value="totp" selected' in setup_start.text
assert 'option value="none" selected' not in setup_start.text
def test_admin_can_delete_user_but_not_self(tmp_path):
db_path = tmp_path / "admin-delete.db"
app = create_app(_build_settings(f"sqlite:///{db_path}", bootstrap_admin_email="admin@example.com"))
with TestClient(app) as admin_client, TestClient(app) as user_client:
admin_register = admin_client.post(
"/auth/register",
json={"email": "admin@example.com", "password": "verystrongPass123"},
)
assert admin_register.status_code == 200
admin_payload = admin_register.json()
admin_id = admin_payload["id"]
admin_csrf = admin_payload["csrf_token"]
user_register = user_client.post(
"/auth/register",
json={"email": "delete-me@example.com", "password": "verystrongPass123"},
)
assert user_register.status_code == 200
user_id = user_register.json()["id"]
delete_user = admin_client.post(
f"/settings/admin/users/{user_id}/delete",
data={"csrf_token": admin_csrf},
follow_redirects=False,
)
assert delete_user.status_code == 303
assert delete_user.headers["location"].startswith("/settings?tab=admin&msg=admin_user_deleted")
me_deleted_user = user_client.get("/me")
assert me_deleted_user.status_code == 401
delete_self = admin_client.post(
f"/settings/admin/users/{admin_id}/delete",
data={"csrf_token": admin_csrf},
follow_redirects=False,
)
assert delete_self.status_code == 400
+53
View File
@@ -0,0 +1,53 @@
from fastapi.testclient import TestClient
def test_login_and_tenant_isolation(app):
with TestClient(app) as client_a, TestClient(app) as client_b:
register_a = client_a.post(
"/auth/register",
json={"email": "a@example.com", "password": "strongpasswordA1"},
)
assert register_a.status_code == 200
csrf_a = register_a.json()["csrf_token"]
register_b = client_b.post(
"/auth/register",
json={"email": "b@example.com", "password": "strongpasswordB1"},
)
assert register_b.status_code == 200
csrf_b = register_b.json()["csrf_token"]
create_a = client_a.post(
"/time-entries",
headers={"x-csrf-token": csrf_a},
json={
"date": "2026-02-24",
"start_time": "08:30",
"end_time": "15:00",
"break_minutes": 30,
},
)
assert create_a.status_code == 200
entry_id = create_a.json()["id"]
list_b = client_b.get("/time-entries")
assert list_b.status_code == 200
assert list_b.json()["items"] == []
patch_b = client_b.patch(
f"/time-entries/{entry_id}",
headers={"x-csrf-token": csrf_b},
json={"break_minutes": 15},
)
assert patch_b.status_code == 404
client_a.post("/auth/logout", headers={"x-csrf-token": csrf_a})
login_a = client_a.post(
"/auth/login",
json={"email": "a@example.com", "password": "strongpasswordA1"},
)
assert login_a.status_code == 200
me_a = client_a.get("/me")
assert me_a.status_code == 200
assert me_a.json()["email"] == "a@example.com"
+68
View File
@@ -0,0 +1,68 @@
from fastapi.testclient import TestClient
def test_bulk_entry_create_and_update(app):
with TestClient(app) as client_a, TestClient(app) as client_b:
register_a = client_a.post(
"/auth/register",
json={"email": "bulk-a@example.com", "password": "strongpasswordBulkA1"},
)
assert register_a.status_code == 200
csrf_a = register_a.json()["csrf_token"]
register_b = client_b.post(
"/auth/register",
json={"email": "bulk-b@example.com", "password": "strongpasswordBulkB1"},
)
assert register_b.status_code == 200
create_range = client_a.post(
"/bulk-entry",
data={
"from_date": "2026-03-02",
"to_date": "2026-03-13",
"weekdays_values": ["0", "1", "2", "3", "4"],
"start_time": "08:00",
"end_time": "12:00",
"break_minutes": "0",
"mode": "only_missing",
"notes": "nachtrag",
"csrf_token": csrf_a,
},
follow_redirects=False,
)
assert create_range.status_code == 303
list_a = client_a.get("/time-entries", params={"from": "2026-03-01", "to": "2026-03-31"})
assert list_a.status_code == 200
assert len(list_a.json()["items"]) == 10
update_range = client_a.post(
"/bulk-entry",
data={
"from_date": "2026-03-02",
"to_date": "2026-03-13",
"weekdays_values": ["0", "1", "2", "3", "4"],
"start_time": "08:30",
"end_time": "13:30",
"break_minutes": "30",
"mode": "upsert",
"notes": "korrigiert",
"csrf_token": csrf_a,
},
follow_redirects=False,
)
assert update_range.status_code == 303
list_a_updated = client_a.get("/time-entries", params={"from": "2026-03-01", "to": "2026-03-31"})
assert list_a_updated.status_code == 200
items = list_a_updated.json()["items"]
assert len(items) == 10
assert items[0]["start_time"] == "08:30"
assert items[0]["end_time"] == "13:30"
assert items[0]["break_minutes"] == 30
assert items[0]["notes"] == "korrigiert"
list_b = client_b.get("/time-entries", params={"from": "2026-03-01", "to": "2026-03-31"})
assert list_b.status_code == 200
assert list_b.json()["items"] == []
+85
View File
@@ -0,0 +1,85 @@
from dataclasses import dataclass
from datetime import date, timedelta
import pytest
from app.services.calculations import (
aggregate_week,
automatic_break_minutes,
compute_net_minutes,
cumulative_delta,
iso_week_bounds,
parse_time_to_minutes,
required_break_minutes_for_span,
)
@dataclass
class FakeEntry:
date: date
start_minutes: int
end_minutes: int
break_minutes: int
def test_compute_net_minutes_and_validation() -> None:
assert compute_net_minutes(8 * 60, 15 * 60, 30) == 390
with pytest.raises(ValueError):
compute_net_minutes(8 * 60, 8 * 60, 0)
with pytest.raises(ValueError):
compute_net_minutes(8 * 60, 12 * 60, 300)
def test_parse_time_to_minutes() -> None:
assert parse_time_to_minutes("08:30") == 510
with pytest.raises(ValueError):
parse_time_to_minutes("8:30")
def test_required_break_minutes_follow_german_thresholds() -> None:
assert required_break_minutes_for_span(6 * 60) == 0
assert required_break_minutes_for_span((6 * 60) + 1) == 30
assert required_break_minutes_for_span(9 * 60) == 30
assert required_break_minutes_for_span((9 * 60) + 1) == 45
def test_automatic_break_minutes_uses_work_span() -> None:
assert automatic_break_minutes(8 * 60, 14 * 60) == 0
assert automatic_break_minutes(8 * 60, (14 * 60) + 1) == 30
assert automatic_break_minutes(8 * 60, 17 * 60) == 30
assert automatic_break_minutes(8 * 60, (17 * 60) + 1) == 45
with pytest.raises(ValueError):
automatic_break_minutes(8 * 60, 8 * 60)
def test_week_aggregation_and_delta() -> None:
week_start, _ = iso_week_bounds(date(2026, 2, 25))
entries = [
FakeEntry(date=week_start, start_minutes=8 * 60, end_minutes=13 * 60, break_minutes=15),
FakeEntry(date=week_start + timedelta(days=1), start_minutes=9 * 60, end_minutes=14 * 60, break_minutes=30),
]
report = aggregate_week(entries, week_start, weekly_target_minutes=1500)
assert report["weekly_ist"] == (285 + 270)
assert report["weekly_soll"] == 1500
assert report["weekly_delta"] == -945
def test_cumulative_delta_multiple_weeks() -> None:
first_week_start, _ = iso_week_bounds(date(2026, 2, 2))
second_week_start = first_week_start + timedelta(days=7)
entries = [
FakeEntry(date=first_week_start, start_minutes=8 * 60, end_minutes=13 * 60, break_minutes=0),
FakeEntry(date=second_week_start, start_minutes=8 * 60, end_minutes=16 * 60, break_minutes=30),
]
result = cumulative_delta(entries, second_week_start, weekly_target_minutes=1500)
# Woche 1: 300 - 1500, Woche 2: 450 - 1500
assert result == -2250
+113
View File
@@ -0,0 +1,113 @@
import re
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.database import get_engine
from app.main import create_app
from app.models import EmailServerConfig, User
def _extract_csrf(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _build_settings(db_url: str) -> Settings:
return Settings(
APP_ENV="test",
DB_URL=db_url,
SESSION_SECRET="test-secret",
COOKIE_SECURE=False,
COOKIE_SAMESITE="lax",
LOGIN_RATE_LIMIT_ATTEMPTS=5,
LOGIN_RATE_LIMIT_WINDOW_MINUTES=15,
EMAIL_VERIFICATION_REQUIRED=True,
)
def test_register_requires_email_verification_with_mail_server(tmp_path, monkeypatch):
db_path = tmp_path / "verify.db"
app = create_app(_build_settings(f"sqlite:///{db_path}"))
sent_mails: list[dict[str, str]] = []
def fake_send_email(*, settings, to_email: str, subject: str, text_body: str) -> None:
sent_mails.append({"to": to_email, "subject": subject, "body": text_body})
monkeypatch.setattr("app.main.send_email", fake_send_email)
with Session(get_engine()) as db:
db.add(
EmailServerConfig(
smtp_host="smtp.test.local",
smtp_port=587,
from_email="noreply@test.local",
from_name="Stundentracker",
use_starttls=True,
use_ssl=False,
verify_tls=False,
registration_mails_enabled=True,
password_reset_mails_enabled=True,
)
)
db.commit()
with TestClient(app) as client:
register_page = client.get("/register")
csrf = _extract_csrf(register_page.text)
register_submit = client.post(
"/register",
data={
"email": "verify-user@example.com",
"password": "strongpasswordVerify1",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert register_submit.status_code == 303
assert register_submit.headers["location"] == "/login?msg=email_verification_sent"
assert len(sent_mails) == 1
assert sent_mails[0]["to"] == "verify-user@example.com"
login_page = client.get("/login")
login_csrf = _extract_csrf(login_page.text)
denied_login = client.post(
"/login",
data={
"email": "verify-user@example.com",
"password": "strongpasswordVerify1",
"csrf_token": login_csrf,
},
follow_redirects=False,
)
assert denied_login.status_code == 403
assert "Bitte zuerst deine E-Mail-Adresse bestätigen" in denied_login.text
link_match = re.search(r"https?://[^\s]+/verify-email\?token=[^\s]+", sent_mails[0]["body"])
assert link_match is not None
verify_response = client.get(link_match.group(0), follow_redirects=False)
assert verify_response.status_code == 303
assert verify_response.headers["location"] == "/login?msg=email_verified"
login_page_after_verify = client.get("/login")
login_csrf_after_verify = _extract_csrf(login_page_after_verify.text)
login_after_verify = client.post(
"/login",
data={
"email": "verify-user@example.com",
"password": "strongpasswordVerify1",
"csrf_token": login_csrf_after_verify,
},
follow_redirects=False,
)
assert login_after_verify.status_code == 303
assert login_after_verify.headers["location"].startswith("/dashboard")
with Session(get_engine()) as db:
verified_user = db.execute(select(User).where(User.email == "verify-user@example.com")).scalar_one()
assert verified_user.email_verified is True
+50
View File
@@ -0,0 +1,50 @@
from fastapi.testclient import TestClient
def test_export_xlsx_and_pdf(app):
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "export@example.com", "password": "strongpasswordExport1"},
)
assert register.status_code == 200
csrf = register.json()["csrf_token"]
create = client.post(
"/time-entries",
headers={"x-csrf-token": csrf},
json={
"date": "2026-03-03",
"start_time": "08:30",
"end_time": "15:00",
"break_minutes": 30,
},
)
assert create.status_code == 200
export_xlsx = client.post(
"/export",
data={
"from_date": "2026-03-01",
"to_date": "2026-03-10",
"format": "xlsx",
"csrf_token": csrf,
},
)
assert export_xlsx.status_code == 200
assert "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" in export_xlsx.headers["content-type"]
assert "attachment;" in export_xlsx.headers["content-disposition"]
assert len(export_xlsx.content) > 200
export_pdf = client.post(
"/export",
data={
"from_date": "2026-03-01",
"to_date": "2026-03-10",
"format": "pdf",
"csrf_token": csrf,
},
)
assert export_pdf.status_code == 200
assert "application/pdf" in export_pdf.headers["content-type"]
assert export_pdf.content.startswith(b"%PDF")
+214
View File
@@ -0,0 +1,214 @@
from datetime import datetime, timedelta, timezone
import re
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.database import get_engine
from app.main import create_app
from app.models import SupportTicket
def _csrf_from_html(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _started_at_from_html(html: str) -> str:
match = re.search(r'name="started_at" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _build_admin_app(db_url: str) -> object:
return create_app(
Settings(
APP_ENV="test",
DB_URL=db_url,
SESSION_SECRET="test-secret",
COOKIE_SECURE=False,
COOKIE_SAMESITE="lax",
LOGIN_RATE_LIMIT_ATTEMPTS=5,
LOGIN_RATE_LIMIT_WINDOW_MINUTES=15,
BOOTSTRAP_ADMIN_EMAIL="admin@example.com",
)
)
def test_public_footer_and_legal_pages_render(app):
with TestClient(app) as client:
response = client.get("/login")
assert response.status_code == 200
assert 'href="/kontakt"' in response.text
assert 'href="/impressum"' in response.text
assert 'href="/datenschutz"' in response.text
impressum = client.get("/impressum")
assert impressum.status_code == 200
assert "Impressum" in impressum.text
privacy = client.get("/datenschutz")
assert privacy.status_code == 200
assert "Datenschutz" in privacy.text
def test_contact_form_creates_ticket(monkeypatch, app):
import app.main as main_module
base_time = datetime(2026, 3, 22, 12, 0, tzinfo=timezone.utc)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client:
form = client.get("/kontakt")
assert form.status_code == 200
csrf = _csrf_from_html(form.text)
started_at = _started_at_from_html(form.text)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time + timedelta(seconds=5))
submit = client.post(
"/kontakt",
data={
"csrf_token": csrf,
"started_at": started_at,
"website": "",
"category": "feature",
"name": "Max Beispiel",
"email": "max@example.com",
"subject": "Bitte Monatsfilter erweitern",
"message": "Ich wünsche mir eine bessere Filterung in der Monatsansicht.",
},
follow_redirects=False,
)
assert submit.status_code == 303
assert submit.headers["location"] == "/kontakt?msg=sent"
with Session(get_engine()) as db:
tickets = db.execute(select(SupportTicket)).scalars().all()
assert len(tickets) == 1
assert tickets[0].category == "feature"
assert tickets[0].status == "open"
assert tickets[0].subject == "Bitte Monatsfilter erweitern"
def test_contact_form_honeypot_blocks_submission(monkeypatch, app):
import app.main as main_module
base_time = datetime(2026, 3, 22, 12, 0, tzinfo=timezone.utc)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client:
form = client.get("/kontakt")
csrf = _csrf_from_html(form.text)
started_at = _started_at_from_html(form.text)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time + timedelta(seconds=5))
submit = client.post(
"/kontakt",
data={
"csrf_token": csrf,
"started_at": started_at,
"website": "spam",
"category": "problem",
"name": "",
"email": "spam@example.com",
"subject": "Spamversuch",
"message": "Das sollte blockiert werden.",
},
)
assert submit.status_code == 429
assert "nicht versendet" in submit.text
with Session(get_engine()) as db:
tickets = db.execute(select(SupportTicket)).scalars().all()
assert tickets == []
def test_admin_can_manage_legal_content_and_tickets(tmp_path, monkeypatch):
import app.main as main_module
db_path = tmp_path / "legal-support.db"
app = _build_admin_app(f"sqlite:///{db_path}")
base_time = datetime(2026, 3, 22, 12, 0, tzinfo=timezone.utc)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as admin_client:
register = admin_client.post(
"/auth/register",
json={"email": "admin@example.com", "password": "verystrongPass123"},
)
assert register.status_code == 200
csrf = register.json()["csrf_token"]
update_legal = admin_client.post(
"/settings/admin/site-content",
data={
"csrf_token": csrf,
"impressum_markdown": "# Impressum\n\n**Stage Test**",
"privacy_markdown": "# Datenschutz\n\nBitte Datenschutz beachten.",
},
follow_redirects=False,
)
assert update_legal.status_code == 303
assert update_legal.headers["location"] == "/settings?tab=admin&msg=site_content_updated"
impressum = admin_client.get("/impressum")
assert impressum.status_code == 200
assert "Stage Test" in impressum.text
form = admin_client.get("/kontakt")
started_at = _started_at_from_html(form.text)
monkeypatch.setattr(main_module, "utc_now", lambda: base_time + timedelta(seconds=5))
submit = admin_client.post(
"/kontakt",
data={
"csrf_token": csrf,
"started_at": started_at,
"website": "",
"category": "problem",
"name": "Admin Test",
"email": "admin@example.com",
"subject": "Ticket bitte schließen",
"message": "Dieses Ticket wird direkt im Adminbereich geschlossen.",
},
follow_redirects=False,
)
assert submit.status_code == 303
with Session(get_engine()) as db:
ticket = db.execute(select(SupportTicket).where(SupportTicket.subject == "Ticket bitte schließen")).scalar_one()
ticket_id = ticket.id
with TestClient(app) as admin_client:
login = admin_client.post(
"/login",
data={
"email": "admin@example.com",
"password": "verystrongPass123",
"csrf_token": _csrf_from_html(admin_client.get("/login").text),
},
follow_redirects=False,
)
assert login.status_code == 303
settings_page = admin_client.get("/settings?tab=admin")
settings_csrf = _csrf_from_html(settings_page.text)
update_ticket = admin_client.post(
f"/settings/admin/tickets/{ticket_id}",
data={
"csrf_token": settings_csrf,
"status": "closed",
"admin_notes": "Geschlossen im Test",
},
follow_redirects=False,
)
assert update_ticket.status_code == 303
assert update_ticket.headers["location"] == "/settings?tab=admin&msg=ticket_updated"
with Session(get_engine()) as db:
ticket = db.get(SupportTicket, ticket_id)
assert ticket is not None
assert ticket.status == "closed"
assert ticket.admin_notes == "Geschlossen im Test"
assert ticket.closed_at is not None
+307
View File
@@ -0,0 +1,307 @@
import re
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.database import get_engine
from app.main import create_app
from app.models import EmailServerConfig, User
def _build_settings(db_url: str, **overrides) -> Settings:
values = {
"APP_ENV": "test",
"DB_URL": db_url,
"SESSION_SECRET": "test-secret",
"COOKIE_SECURE": False,
"COOKIE_SAMESITE": "lax",
"LOGIN_RATE_LIMIT_ATTEMPTS": 5,
"LOGIN_RATE_LIMIT_WINDOW_MINUTES": 15,
}
values.update(overrides)
return Settings(**values)
def _extract_csrf(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _find_latest_link(sent_mails: list[dict[str, str]], path_fragment: str) -> str:
pattern = re.compile(rf"https?://[^\s]+{re.escape(path_fragment)}[^\s]*")
for mail in reversed(sent_mails):
match = pattern.search(mail["body"])
if match is not None:
return match.group(0)
raise AssertionError(f"no mail with link containing {path_fragment!r}")
def _insert_mail_config() -> None:
with Session(get_engine()) as db:
db.add(
EmailServerConfig(
smtp_host="smtp.test.local",
smtp_port=587,
from_email="noreply@test.local",
from_name="Stundenfuchs",
use_starttls=True,
use_ssl=False,
verify_tls=False,
registration_mails_enabled=False,
password_reset_mails_enabled=True,
)
)
db.commit()
def test_login_rate_limit_ignores_spoofed_x_forwarded_for(app):
email = "rate-limit@example.com"
password = "strongpasswordRate1"
with TestClient(app) as register_client:
register = register_client.post(
"/auth/register",
json={"email": email, "password": password},
)
assert register.status_code == 200
with TestClient(app) as attacker:
for idx in range(5):
response = attacker.post(
"/auth/login",
headers={"x-forwarded-for": f"198.51.100.{idx}"},
json={"email": email, "password": "wrong-password"},
)
assert response.status_code == 401
blocked = attacker.post(
"/auth/login",
headers={"x-forwarded-for": "203.0.113.77"},
json={"email": email, "password": "wrong-password"},
)
assert blocked.status_code == 429
def test_password_reset_new_request_invalidates_previous_token(tmp_path, monkeypatch):
db_path = tmp_path / "reset-rotation.db"
app = create_app(_build_settings(f"sqlite:///{db_path}"))
sent_mails: list[dict[str, str]] = []
def fake_send_email(*, settings, to_email: str, subject: str, text_body: str) -> None:
sent_mails.append({"to": to_email, "subject": subject, "body": text_body})
monkeypatch.setattr("app.main.send_email", fake_send_email)
_insert_mail_config()
with TestClient(app) as auth_client:
register = auth_client.post(
"/auth/register",
json={"email": "reset-user@example.com", "password": "strongpasswordReset1"},
)
assert register.status_code == 200
with TestClient(app) as reset_client:
request_page = reset_client.get("/password-reset/request")
request_csrf = _extract_csrf(request_page.text)
first_request = reset_client.post(
"/password-reset/request",
data={"email": "reset-user@example.com", "csrf_token": request_csrf},
)
assert first_request.status_code == 200
first_link = _find_latest_link(sent_mails, "/password-reset/confirm?token=")
request_page_again = reset_client.get("/password-reset/request")
request_csrf_again = _extract_csrf(request_page_again.text)
second_request = reset_client.post(
"/password-reset/request",
data={"email": "reset-user@example.com", "csrf_token": request_csrf_again},
)
assert second_request.status_code == 200
second_link = _find_latest_link(sent_mails, "/password-reset/confirm?token=")
assert first_link != second_link
first_token_page = reset_client.get(first_link)
assert first_token_page.status_code == 400
second_token_page = reset_client.get(second_link)
assert second_token_page.status_code == 200
assert 'name="token"' in second_token_page.text
def test_password_change_invalidates_existing_reset_tokens(tmp_path, monkeypatch):
db_path = tmp_path / "reset-password-change.db"
app = create_app(_build_settings(f"sqlite:///{db_path}"))
sent_mails: list[dict[str, str]] = []
def fake_send_email(*, settings, to_email: str, subject: str, text_body: str) -> None:
sent_mails.append({"to": to_email, "subject": subject, "body": text_body})
monkeypatch.setattr("app.main.send_email", fake_send_email)
_insert_mail_config()
password = "strongpasswordReset2"
with TestClient(app) as user_client, TestClient(app) as reset_client:
register = user_client.post(
"/auth/register",
json={"email": "password-change@example.com", "password": password},
)
assert register.status_code == 200
csrf_token = register.json()["csrf_token"]
request_page = reset_client.get("/password-reset/request")
request_csrf = _extract_csrf(request_page.text)
reset_request = reset_client.post(
"/password-reset/request",
data={"email": "password-change@example.com", "csrf_token": request_csrf},
)
assert reset_request.status_code == 200
reset_link = _find_latest_link(sent_mails, "/password-reset/confirm?token=")
change_password = user_client.post(
"/settings/password",
data={
"current_password": password,
"new_password": "strongpasswordReset3",
"new_password_repeat": "strongpasswordReset3",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
assert change_password.status_code == 303
assert change_password.headers["location"] == "/settings?msg=password_updated"
expired_reset = reset_client.get(reset_link)
assert expired_reset.status_code == 400
def test_email_change_requires_reverification_and_clears_session(tmp_path, monkeypatch):
db_path = tmp_path / "email-change.db"
app = create_app(
_build_settings(
f"sqlite:///{db_path}",
EMAIL_VERIFICATION_REQUIRED=True,
)
)
sent_mails: list[dict[str, str]] = []
def fake_send_email(*, settings, to_email: str, subject: str, text_body: str) -> None:
sent_mails.append({"to": to_email, "subject": subject, "body": text_body})
monkeypatch.setattr("app.main.send_email", fake_send_email)
_insert_mail_config()
password = "strongpasswordVerify2"
with TestClient(app) as client:
register_page = client.get("/register")
register_csrf = _extract_csrf(register_page.text)
register_submit = client.post(
"/register",
data={
"email": "verified-before-change@example.com",
"password": password,
"csrf_token": register_csrf,
},
follow_redirects=False,
)
assert register_submit.status_code == 303
assert register_submit.headers["location"] == "/login?msg=email_verification_sent"
verify_link = _find_latest_link(sent_mails, "/verify-email?token=")
verify_response = client.get(verify_link, follow_redirects=False)
assert verify_response.status_code == 303
login_page = client.get("/login")
login_csrf = _extract_csrf(login_page.text)
login_submit = client.post(
"/login",
data={
"email": "verified-before-change@example.com",
"password": password,
"csrf_token": login_csrf,
},
follow_redirects=False,
)
assert login_submit.status_code == 303
settings_page = client.get("/settings")
settings_csrf = _extract_csrf(settings_page.text)
profile_update = client.post(
"/settings/profile",
data={
"email": "changed-address@example.com",
"current_password": password,
"csrf_token": settings_csrf,
},
follow_redirects=False,
)
assert profile_update.status_code == 303
assert profile_update.headers["location"] == "/login?msg=email_verification_sent"
me_after_change = client.get("/me")
assert me_after_change.status_code == 401
login_page_after_change = client.get("/login")
login_csrf_after_change = _extract_csrf(login_page_after_change.text)
blocked_login = client.post(
"/login",
data={
"email": "changed-address@example.com",
"password": password,
"csrf_token": login_csrf_after_change,
},
follow_redirects=False,
)
assert blocked_login.status_code == 403
assert sent_mails[-1]["to"] == "changed-address@example.com"
with Session(get_engine()) as db:
updated_user = db.execute(select(User).where(User.email == "changed-address@example.com")).scalar_one()
assert updated_user.email_verified is False
def test_api_mfa_resend_respects_cooldown(tmp_path, monkeypatch):
db_path = tmp_path / "mfa-resend.db"
app = create_app(_build_settings(f"sqlite:///{db_path}"))
sent_mails: list[dict[str, str]] = []
def fake_send_email(*, settings, to_email: str, subject: str, text_body: str) -> None:
sent_mails.append({"to": to_email, "subject": subject, "body": text_body})
monkeypatch.setattr("app.main.send_email", fake_send_email)
_insert_mail_config()
password = "strongpasswordMfa1"
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "mfa-resend@example.com", "password": password},
)
assert register.status_code == 200
csrf_token = register.json()["csrf_token"]
enable_email_mfa = client.post(
"/settings/mfa",
data={
"csrf_token": csrf_token,
"mfa_method": "email",
"current_password": password,
},
follow_redirects=False,
)
assert enable_email_mfa.status_code == 303
logout = client.post("/auth/logout", headers={"x-csrf-token": csrf_token})
assert logout.status_code == 200
login = client.post("/auth/login", json={"email": "mfa-resend@example.com", "password": password})
assert login.status_code == 200
assert login.json()["mfa_required"] is True
pending_csrf = login.json()["csrf_token"]
resend = client.post("/auth/mfa/resend", headers={"x-csrf-token": pending_csrf})
assert resend.status_code == 429
assert "Bitte kurz warten" in resend.text
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
from fastapi.testclient import TestClient
def test_weekly_target_scopes(app):
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "scope@example.com", "password": "strongpasswordScope1"},
)
assert register.status_code == 200
csrf = register.json()["csrf_token"]
create_w1 = client.post(
"/time-entries",
headers={"x-csrf-token": csrf},
json={
"date": "2026-03-02", # Montag
"start_time": "08:00",
"end_time": "13:00",
"break_minutes": 0,
},
)
assert create_w1.status_code == 200
create_w2 = client.post(
"/time-entries",
headers={"x-csrf-token": csrf},
json={
"date": "2026-03-09", # Folgewoche
"start_time": "08:00",
"end_time": "13:00",
"break_minutes": 0,
},
)
assert create_w2.status_code == 200
week1_default = client.get("/reports/week", params={"date": "2026-03-02"})
assert week1_default.status_code == 200
assert week1_default.json()["weekly_soll_minutes"] == 1500
change_current = client.post(
"/weekly-target",
data={
"week_start": "2026-03-02",
"weekly_target_hours": "20",
"scope": "current_week",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert change_current.status_code == 303
week1_changed = client.get("/reports/week", params={"date": "2026-03-02"})
week2_after_current = client.get("/reports/week", params={"date": "2026-03-09"})
assert week1_changed.json()["weekly_soll_minutes"] == 1200
assert week2_after_current.json()["weekly_soll_minutes"] == 1500
change_future = client.post(
"/weekly-target",
data={
"week_start": "2026-03-09",
"weekly_target_hours": "30",
"scope": "from_current_week",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert change_future.status_code == 303
week2_changed = client.get("/reports/week", params={"date": "2026-03-09"})
assert week2_changed.json()["weekly_soll_minutes"] == 1800
change_all = client.post(
"/weekly-target",
data={
"week_start": "2026-03-09",
"weekly_target_hours": "22",
"scope": "all_weeks",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert change_all.status_code == 303
week1_all = client.get("/reports/week", params={"date": "2026-03-02"})
week2_all = client.get("/reports/week", params={"date": "2026-03-09"})
assert week1_all.json()["weekly_soll_minutes"] == 1320
assert week2_all.json()["weekly_soll_minutes"] == 1320