test: P1-Coverage für Scheduler-Idempotenz, Bradford-Fensterrand, FZA-Faktor+Kappung, Cross-Company-RLS
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
2026-09-03 02:10:19 +02:00
co-authored by Claude Sonnet 5
parent 9afc29a8bb
commit fd752878b0
3 changed files with 899 additions and 0 deletions
+350
View File
@@ -20,6 +20,7 @@ import pytest
import pytest_asyncio
from datetime import date, time, timedelta
from decimal import Decimal
from uuid import UUID
from httpx import AsyncClient
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -612,3 +613,352 @@ async def test_create_absence_returns_warnings(
data2 = resp2.json()
assert "warnings" in data2
assert any("Überschneidung" in w for w in data2["warnings"]), data2["warnings"]
# ── P1-Rest: postgres-expert Audit, 4 gezielte Tests ────────────────────────────
#
# 1. Scheduler-Idempotenz Überstunden-Verfall (report_service.apply_overtime_expiry_if_needed)
# 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope (absence_service.get_sick_stats)
# 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert (report_service._recalculate_overtime_balance)
# 4. Cross-Company-RLS bei Vertretern (absence_service.list_absences as_substitute=True)
from types import SimpleNamespace
# ── 1. Scheduler-Idempotenz (Überstunden-Verfall) ───────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_overtime_expiry_idempotent_second_call_noop(db_session: AsyncSession):
"""Ein zweiter Aufruf am selben Tag darf keinen zweiten Abzug auslösen
(verhindert Doppel-Kappung bei doppeltem Scheduler-Lauf)."""
from app.services.report_service import apply_overtime_expiry_if_needed
company = SimpleNamespace(
overtime_expiry_enabled=True,
overtime_expiry_month=1,
overtime_expiry_day=1,
overtime_max_carryover_hours=5,
)
bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None)
changed_first = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed_first is True
assert bal.total_hours == Decimal("5"), "Kappung auf max_carryover_hours erwartet"
# Zweiter Aufruf am selben Tag (last_expiry_applied_at wurde eben gesetzt) -> no-op
changed_second = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed_second is False
assert bal.total_hours == Decimal("5"), "Kein zweiter Abzug bei erneutem Aufruf am selben Tag"
@pytest.mark.asyncio(loop_scope="session")
async def test_overtime_expiry_invalid_date_silent_skip(db_session: AsyncSession):
"""Ungültiges Verfallsdatum (29. Februar in einem Nicht-Schaltjahr) darf nicht
crashen (kein propagierter ValueError), sondern still übersprungen werden.
Hinweis: nutzt das reale Systemdatum (Funktion ruft intern date.today() auf,
kein Mock-Punkt vorhanden). Das aktuelle Jahr (Testlauf 2026) ist kein
Schaltjahr, ebenso das Vorjahr 2025 -- Test bleibt bis 2027 gültig
(2028 ist wieder ein Schaltjahr, dann müsste der Test angepasst werden)."""
from app.services.report_service import apply_overtime_expiry_if_needed
company = SimpleNamespace(
overtime_expiry_enabled=True,
overtime_expiry_month=2,
overtime_expiry_day=29,
overtime_max_carryover_hours=5,
)
bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None)
changed = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed is False
assert bal.total_hours == Decimal("20"), "Bei ungültigem Datum darf keine Kappung stattfinden"
# ── 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope ──────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_bradford_window_boundary_and_manager_department_scope(
client: AsyncClient, db_session: AsyncSession,
):
"""(a) Eine Krank-Episode die GENAU am Rand des 365-Tage-Fensters endet
(end_date == window_start) muss mitgezählt werden, eine die einen Tag davor
endet nicht (Off-by-one, quadratisch empfindlich für den Bradford-Faktor).
(b) Ein MANAGER mit department_id sieht nur Bradford-Stats des eigenen
Departments, nicht firmenweit (DSGVO-Datensparsamkeit)."""
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceCategory, AbsenceType
from app.models.user import UserRole
from app.services.absence_service import absence_service
reg = await client.post("/api/v1/auth/register", json={
"company_name": "Bradford Edge GmbH",
"first_name": "Brad", "last_name": "Admin",
"email": "admin@bradfordedge.de", "password": "Secret123",
})
assert reg.status_code == 201, reg.text
admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
me = (await client.get("/api/v1/auth/me", headers=admin_headers)).json()
company_id = me["company_id"]
dep_a = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung A"}, headers=admin_headers)
assert dep_a.status_code == 201, dep_a.text
dep_b = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung B"}, headers=admin_headers)
assert dep_b.status_code == 201, dep_b.text
dep_a_id = dep_a.json()["id"]
dep_b_id = dep_b.json()["id"]
mgr = await client.post("/api/v1/users/invite", json={
"first_name": "Manager", "last_name": "A", "email": "manager.a@bradfordedge.de",
"role": "MANAGER", "department_id": dep_a_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert mgr.status_code == 201, mgr.text
mgr_id = mgr.json()["id"]
emp_a = await client.post("/api/v1/users/invite", json={
"first_name": "Emp", "last_name": "A", "email": "emp.a@bradfordedge.de",
"role": "EMPLOYEE", "department_id": dep_a_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert emp_a.status_code == 201, emp_a.text
emp_a_id = emp_a.json()["id"]
emp_b = await client.post("/api/v1/users/invite", json={
"first_name": "Emp", "last_name": "B", "email": "emp.b@bradfordedge.de",
"role": "EMPLOYEE", "department_id": dep_b_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert emp_b.status_code == 201, emp_b.text
emp_b_id = emp_b.json()["id"]
types_resp = await client.get("/api/v1/absence-types/", headers=admin_headers)
sick_type = next(t for t in types_resp.json() if t["category"] == "sick")
sick_type_id = sick_type["id"]
ref_date = date.today()
window_start = ref_date - timedelta(days=365)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
# Emp A: eine Episode GENAU am Fensterrand (muss zählen) + eine Episode
# einen Tag VOR dem Fensterrand (darf nicht zählen).
in_window = Absence(
user_id=emp_a_id, type_id=sick_type_id,
start_date=window_start, end_date=window_start,
working_days=Decimal("1"), status=AbsenceStatus.APPROVED,
)
out_of_window = Absence(
user_id=emp_a_id, type_id=sick_type_id,
start_date=window_start - timedelta(days=3), end_date=window_start - timedelta(days=1),
working_days=Decimal("3"), status=AbsenceStatus.APPROVED,
)
# Emp B (anderes Department): Episode innerhalb des Fensters.
emp_b_absence = Absence(
user_id=emp_b_id, type_id=sick_type_id,
start_date=ref_date - timedelta(days=10), end_date=ref_date - timedelta(days=8),
working_days=Decimal("3"), status=AbsenceStatus.APPROVED,
)
db_session.add_all([in_window, out_of_window, emp_b_absence])
await db_session.flush()
# (a) Fensterrand: Admin sieht firmenweit -> Emp A hat genau 1 Episode
# (nur die am Rand liegende zählt, die davor liegende fällt raus).
admin_ns = SimpleNamespace(role=UserRole.COMPANY_ADMIN, department_id=None)
stats_admin = await absence_service.get_sick_stats(
UUID(company_id) if not isinstance(company_id, UUID) else company_id,
admin_ns, ref_date, db_session,
)
emp_a_stats = next(s for s in stats_admin if str(s["user_id"]) == emp_a_id)
assert emp_a_stats["episodes"] == 1, (
f"Off-by-one am Fensterrand: erwartet genau 1 Episode (window_start selbst zählt, "
f"ein Tag davor nicht), got {emp_a_stats['episodes']}"
)
assert emp_a_stats["total_days"] == pytest.approx(1.0)
# Admin sieht auch Emp B (firmenweit, keine Department-Einschränkung).
assert any(str(s["user_id"]) == emp_b_id for s in stats_admin)
# (b) MANAGER mit department_id sieht nur sein eigenes Department.
mgr_ns = SimpleNamespace(role=UserRole.MANAGER, department_id=UUID(dep_a_id))
stats_mgr = await absence_service.get_sick_stats(
UUID(company_id) if not isinstance(company_id, UUID) else company_id,
mgr_ns, ref_date, db_session,
)
assert any(str(s["user_id"]) == emp_a_id for s in stats_mgr), "Manager muss eigenes Department sehen"
assert not any(str(s["user_id"]) == emp_b_id for s in stats_mgr), (
"DSGVO-Lücke: MANAGER sieht Bradford-Stats von Mitarbeitern außerhalb des eigenen Departments"
)
await db_session.commit()
# ── 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert ───────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_special_assignment_factor_combined_with_overtime_cap(
client: AsyncClient, db_session: AsyncSession,
):
"""SpecialAssignment mit Faktor 1.5 (mode=fza) UND company.overtime_cap_hours
gleichzeitig: prüft anhand des tatsächlichen Codes, dass die Kappung auf den
FAKTORISIERTEN Wert angewendet wird (hours_by_date wird bereits mit dem Faktor
multipliziert berechnet, siehe report_service.py Zeile ~239, bevor die Kappung
in Zeile ~253 greift). Das ist kein Bug, sondern dokumentiertes Ist-Verhalten:
die Sondervertretungs-Gewichtung fließt VOR die Kappung ein."""
from app.models.special_assignment import AssignmentMode, SpecialAssignment
from app.models.time_entry import EntryStatus, TimeEntry
from app.services.report_service import _recalculate_overtime_balance
reg = await client.post("/api/v1/auth/register", json={
"company_name": "FZA Cap Edge GmbH",
"first_name": "Cap", "last_name": "Admin",
"email": "admin@fzacapedge.de", "password": "Secret123",
})
assert reg.status_code == 201, reg.text
admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
cap_resp = await client.patch("/api/v1/companies/me", json={"overtime_cap_hours": 5}, headers=admin_headers)
assert cap_resp.status_code == 200, cap_resp.text
emp = await client.post("/api/v1/users/invite", json={
"first_name": "Cap", "last_name": "Employee", "email": "emp@fzacapedge.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=admin_headers)
assert emp.status_code == 201, emp.text
emp_id = emp.json()["id"]
# Vergangener Werktag (Mo-Fr), weit genug in der Vergangenheit um nicht
# mit anderen Tests zu kollidieren.
workday = date.today() - timedelta(days=90)
while workday.weekday() >= 5:
workday -= timedelta(days=1)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
user = await db_session.get(User, emp_id)
assert user is not None
# 10h gearbeitet (08:00-18:00, keine Pause) an einem Standard-8h-Tag.
entry = TimeEntry(
user_id=emp_id, date=workday,
start_time=time(8, 0), end_time=time(18, 0),
break_minutes=0, status=EntryStatus.APPROVED,
)
db_session.add(entry)
sa = SpecialAssignment(
user_id=emp_id, company_id=UUID(str(user.company_id)),
date_from=workday, date_to=workday,
factor=Decimal("1.5"), mode=AssignmentMode.fza,
label="Nachtschicht-Zuschlag Test",
)
db_session.add(sa)
await db_session.flush()
bal = await _recalculate_overtime_balance(user, None, db_session)
# Roh: 10h - 8h Soll = 2h Überstunden. Faktorisiert: 10h*1.5=15h - 8h = 7h.
# Kappung bei 5h -> falls auf faktorisierten Wert angewendet (Ist-Verhalten
# laut Code) landet total_hours bei genau 5, NICHT bei den rohen 2h.
assert bal.total_hours == Decimal("5"), (
f"Erwartetes Ist-Verhalten (Kappung greift auf den faktorisierten Wert, "
f"nicht auf den rohen ArbZG-Überstundenwert) nicht bestätigt, got {bal.total_hours}. "
f"Falls dies fachlich falsch ist, ist das ein Bug in report_service._recalculate_overtime_balance "
f"(Kappung sollte ggf. vor Faktor-Anwendung greifen) -- hier nur dokumentiert, nicht gefixt."
)
await db_session.commit()
# ── 4. Cross-Company-RLS bei Vertretern (list_absences as_substitute) ──────────
@pytest.mark.asyncio(loop_scope="session")
async def test_list_absences_as_substitute_cross_company_isolation(
client: AsyncClient, db_session: AsyncSession,
):
"""Defense-in-Depth auf Service-Ebene (nicht nur Erstell-Validierung):
list_absences(..., as_substitute=True) mit company_id von Firma B darf
KEINE Anträge aus Firma A liefern, selbst wenn (hypothetisch, z.B. durch
einen Bug anderswo) ein User aus Firma A als substitute_id auf einen
Antrag aus Firma A gesetzt wurde und ein gleich benannter/verwechselter
User aus Firma B abgefragt wird. Der Query joint über User.company_id ==
company_id -- geprüft wird hier direkt auf Service-Ebene."""
from app.models.absence import Absence, AbsenceStatus
from app.models.user import UserRole
from app.services.absence_service import absence_service
# Firma A: Antragsteller + Vertreter, beide in Firma A.
reg_a = await client.post("/api/v1/auth/register", json={
"company_name": "Substitute Cross A GmbH",
"first_name": "SubA", "last_name": "Admin",
"email": "admin@subcrossa.de", "password": "Secret123",
})
assert reg_a.status_code == 201, reg_a.text
headers_a = {"Authorization": f"Bearer {reg_a.json()['access_token']}"}
me_a = (await client.get("/api/v1/auth/me", headers=headers_a)).json()
company_a_id = me_a["company_id"]
sub_a = await client.post("/api/v1/users/invite", json={
"first_name": "Sub", "last_name": "InA", "email": "sub.a@subcrossa.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=headers_a)
assert sub_a.status_code == 201, sub_a.text
sub_a_id = sub_a.json()["id"]
types_a = await client.get("/api/v1/absence-types/", headers=headers_a)
vac_type_a = next(t for t in types_a.json() if t["name"] == "Urlaub")["id"]
# Firma B: eigener Vertreter-Kandidat (gleiche Rolle, andere Firma).
reg_b = await client.post("/api/v1/auth/register", json={
"company_name": "Substitute Cross B GmbH",
"first_name": "SubB", "last_name": "Admin",
"email": "admin@subcrossb.de", "password": "Secret123",
})
assert reg_b.status_code == 201, reg_b.text
headers_b = {"Authorization": f"Bearer {reg_b.json()['access_token']}"}
me_b = (await client.get("/api/v1/auth/me", headers=headers_b)).json()
company_b_id = me_b["company_id"]
# Absence in Firma A, mit sub_a (Firma A) als Vertretung -- über Model
# direkt angelegt um Overlap-/Validierungslogik des Create-Endpunkts zu
# umgehen und die Service-Query isoliert zu prüfen.
start = date.today() + timedelta(days=200)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
absence_a = Absence(
user_id=me_a["id"], type_id=vac_type_a,
start_date=start, end_date=start,
working_days=Decimal("1"), status=AbsenceStatus.PENDING,
substitute_id=sub_a_id,
)
db_session.add(absence_a)
await db_session.flush()
# Query 1: sub_a (echter Vertreter, Firma A) mit company_id=Firma A -> sieht den Antrag.
sub_a_user = await db_session.get(User, sub_a_id)
total_a, absences_a = await absence_service.list_absences(
UUID(company_a_id), sub_a_user, db_session, as_substitute=True,
)
assert total_a == 1
assert absences_a[0].id == absence_a.id
# Query 2: Admin von Firma B (current_user.id != sub_a_id, aber selbst wenn
# ein Aufrufer aus Firma B versucht mit fremder company_id_a zu fragen, ist
# der Sicherheits-Layer die current_user.id im WHERE Absence.substitute_id
# == current_user.id -- entscheidend ist aber der company_id-Join). Hier:
# regulärer Aufruf mit Firma B's eigener company_id liefert 0 Treffer,
# obwohl der Antrag aus Firma A existiert.
admin_b_user = await db_session.get(User, me_b["id"])
# current_user hier künstlich auf sub_a_id "verfälscht" simulieren ist nicht
# möglich ohne DB-User -- stattdessen der eigentliche Defense-in-Depth-Check:
# company_id explizit auf Firma B gesetzt, obwohl ein (hypothetischer)
# Vertreter mit gleicher User-Id in Firma A existieren würde. Da UUIDs global
# eindeutig sind, testen wir die Kernaussage direkt: Query mit company_b_id
# + sub_a_user (der in Wahrheit zu Firma A gehört, aber hier als current_user
# übergeben wird) liefert trotzdem 0 Treffer, weil der Join User.company_id
# == company_id (Firma B) den Antrag aus Firma A aussortiert.
total_cross, absences_cross = await absence_service.list_absences(
UUID(company_b_id), sub_a_user, db_session, as_substitute=True,
)
assert total_cross == 0, (
"RLS-Lücke: Vertreter-Query lieferte einen Antrag aus einer anderen Firma zurück "
"(company_id-Join in list_absences greift nicht als Defense-in-Depth)"
)
assert absences_cross == []
await db_session.commit()