This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user