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
+549
View File
@@ -3349,3 +3349,552 @@ Keine Commits in dieser Session.
- frontend/src/pages/KioskStampPage.tsx | 279 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- - frontend/src/pages/KioskStampPage.tsx | 279 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
--- ---
## 2026-09-02 15:07 15:08 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- b8073fa docs(devlog): Sessions vom 2026-08-05 bis 2026-09-02 nachtragen
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 15:09 15:09 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 15:09 15:10 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 15:11 15:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 22:20 22:20 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 22:21 22:23 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 22:27 22:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 52ecd9e chore(routers): toten Router absence.py entfernen
### Geänderte Dateien
- backend/app/routers/absence.py | 159 ---------------------------------------------------------------------------------------
---
## 2026-09-02 22:28 22:29 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- c733ddf fix(redis): gepoolten async-Redis-Client statt Connect/Close pro Request
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:32 22:37 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:38 22:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:38 22:41 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:41 22:41 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:42 22:42 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:42 22:46 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/kiosk_security.py | 5 ++---
- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++--
- backend/app/main.py | 2 ++
- backend/app/routers/auth.py | 32 ++++++++++++++------------------
- frontend/src/api/client.ts | 6 ++++++
---
## 2026-09-02 22:46 22:47 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- ad0bc62 ci(frontend): Build-Job als Gate im Security-Workflow ergänzen
### Geänderte Dateien
- .gitea/workflows/security.yml | 23 +++++++++++++++++++++++
---
## 2026-09-02 22:47 22:49 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 538cbb0 test(coverage): pytest-cov einbauen (term-missing Report)
### Geänderte Dateien
- .gitignore | 3 +++
- backend/pytest.ini | 2 +-
---
## 2026-09-02 22:53 22:53 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 3 +++
- backend/pytest.ini | 2 +-
---
## 2026-09-02 22:54 22:54 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 3 +++
- backend/pytest.ini | 2 +-
---
## 2026-09-02 23:03 23:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 3 +++
- backend/pytest.ini | 2 +-
---
## 2026-09-02 23:03 23:04 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 3 +++
- backend/pytest.ini | 2 +-
---
## 2026-09-02 23:05 23:05 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- c1572c3 docs(api): 7 fehlende Router nachgezogen
### Geänderte Dateien
- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 23:07 23:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 23:09 23:09 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-02 23:09 23:13 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- aba0b53 fix(docs): StaticFiles-Mount-Pfad korrigieren (static statt app/static)
- 4e30c55 fix(docs): Swagger-Assets nach backend/static/ verschoben (nginx-Alias)
- 9f9dfb5 fix(docs): Swagger/ReDoc-Assets lokal statt von cdn.jsdelivr.net
### Geänderte Dateien
- backend/app/main.py | 2 +-
---
## 2026-09-02 23:15 23:17 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/main.py | 2 +-
---
## 2026-09-02 23:17 23:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- b7bec79 fix(setup): nginx-Template im Installer auf Prod-Stand bringen
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:17 23:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:19 23:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:22 23:22 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:24 23:24 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:25 23:25 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-02 23:35 23:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/tests/test_coverage_gaps.py | 5 +++--
---
## 2026-09-02 23:49 23:55 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- ac496a5 fix(absences): POST /absences/ reicht warnings jetzt an den Client durch
- 3cf396b fix(tests): Testdaten für Jahreswechsel-Regressionstest korrigieren
- 548738c fix(absences): Feiertage über Jahreswechsel korrekt aus working_days rausrechnen
### Geänderte Dateien
- backend/app/routers/absences.py | 10 ++++++++--
- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------
---
## 2026-09-02 23:55 23:55 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/routers/absences.py | 10 ++++++++--
- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------
---
## 2026-09-02 23:56 00:01 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/routers/absences.py | 10 ++++++++--
- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------
---
## 2026-09-03 00:02 00:02 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/routers/absences.py | 10 ++++++++--
- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------
---
## 2026-09-03 00:03 00:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 5ba5a99 docs: README erstellt, Debian statt Ubuntu korrigiert
### Geänderte Dateien
- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- setup_server.sh | 2 +-
---
## 2026-09-03 00:03 00:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- setup_server.sh | 2 +-
---
## 2026-09-03 00:04 00:04 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- setup_server.sh | 2 +-
---
## 2026-09-03 00:04 00:04 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- setup_server.sh | 2 +-
---
## 2026-09-03 00:05 00:05 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- setup_server.sh | 2 +-
---
## 2026-09-03 00:06 00:09 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/routers/auth.py | 29 +++++----
- backend/app/services/auth_service.py | 210 ++++++++++++++++++++++++++++++++---------------------------------
- backend/app/services/kiosk_auth_service.py | 191 ++++++++++++++++++++++++++++-------------------------------
- backend/app/services/kiosk_session_service.py | 26 +++-----
- backend/app/services/public_stamp_session_service.py | 16 ++---
---
## 2026-09-03 00:12 00:14 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 107cba9 fix(redis): INCR+EXPIRE atomar via Pipeline (MULTI/EXEC)
### Geänderte Dateien
- backend/app/routers/auth.py | 8 ++++++--
- backend/app/services/auth_service.py | 8 ++++++--
- backend/app/services/kiosk_auth_service.py | 8 ++++++--
---
## 2026-09-03 01:59 02:00 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/redis.py | 14 +++++++++++++-
- backend/app/main.py | 11 +++++++++++
---
## 2026-09-03 02:04 02:05 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/core/redis.py | 14 +++++++++++++-
- backend/app/main.py | 11 +++++++++++
---
+350
View File
@@ -20,6 +20,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
from datetime import date, time, timedelta from datetime import date, time, timedelta
from decimal import Decimal from decimal import Decimal
from uuid import UUID
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -612,3 +613,352 @@ async def test_create_absence_returns_warnings(
data2 = resp2.json() data2 = resp2.json()
assert "warnings" in data2 assert "warnings" in data2
assert any("Überschneidung" in w for w in data2["warnings"]), data2["warnings"] 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()