Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"""Tests für Busylight-Pull-Endpoint und Token-Verwaltung."""
import pytest
import pytest_asyncio
from datetime import date
from httpx import AsyncClient
# ── Fixtures: eigene Company für Busylight-Tests ─────────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def bl_company(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"company_name": "Busylight GmbH",
"first_name": "Light",
"last_name": "Admin",
"email": "admin@busylight.de",
"password": "Secret123",
})
assert resp.status_code == 201
tokens = resp.json()
me = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
return {"tokens": tokens, "user": me.json()}
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def bl_headers(bl_company):
return {"Authorization": f"Bearer {bl_company['tokens']['access_token']}"}
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def bl_user_with_personnel_number(client: AsyncClient, bl_headers, bl_company):
"""Setzt personnel_number am Admin-User."""
user_id = bl_company["user"]["id"]
resp = await client.patch(
f"/api/v1/users/{user_id}",
json={"personnel_number": "0001"},
headers=bl_headers,
)
assert resp.status_code == 200
return resp.json()
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def bl_token(client: AsyncClient, bl_headers):
"""Generiert Token via Rotate-Endpoint und gibt Klartext zurück."""
resp = await client.post("/api/v1/companies/me/busylight-token/rotate", headers=bl_headers)
assert resp.status_code == 200
return resp.json()["token"]
# ── Token-Verwaltung ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_rotate_returns_plaintext_once(client: AsyncClient, bl_headers):
resp = await client.post("/api/v1/companies/me/busylight-token/rotate", headers=bl_headers)
assert resp.status_code == 200
body = resp.json()
assert "token" in body and len(body["token"]) >= 32
assert "created_at" in body
@pytest.mark.asyncio
async def test_token_status_after_rotate(client: AsyncClient, bl_headers, bl_token):
resp = await client.get("/api/v1/companies/me/busylight-token", headers=bl_headers)
assert resp.status_code == 200
body = resp.json()
assert body["configured"] is True
assert body["created_at"] is not None
# ── Pull-Endpoint Auth ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pull_without_token_returns_401(client: AsyncClient):
resp = await client.get("/api/v1/busylight/users")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_pull_with_invalid_token_returns_401(client: AsyncClient):
resp = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": "Bearer not-a-valid-token-abc"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_pull_with_valid_token_returns_200(
client: AsyncClient, bl_token, bl_user_with_personnel_number
):
resp = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {bl_token}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["date"] == str(date.today())
assert isinstance(body["users"], list)
pn = {u["personnel_number"] for u in body["users"]}
assert "0001" in pn
@pytest.mark.asyncio
async def test_pull_omits_users_without_personnel_number(
client: AsyncClient, bl_token, bl_headers
):
"""User ohne personnel_number tauchen nicht in der Liste auf."""
invite = await client.post(
"/api/v1/users/invite",
json={
"email": "ohne_nr@busylight.de",
"first_name": "Ohne",
"last_name": "Nummer",
"role": "EMPLOYEE",
},
headers=bl_headers,
)
assert invite.status_code in (200, 201)
resp = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {bl_token}"},
)
assert resp.status_code == 200
names = {u["full_name"] for u in resp.json()["users"]}
assert "Ohne Nummer" not in names
@pytest.mark.asyncio
async def test_pull_includes_today_absence_with_category(
client: AsyncClient, bl_token, bl_headers, bl_user_with_personnel_number
):
"""Quick-Sick erstellt approved-Krank-Absence für heute → muss in absences_today auftauchen."""
today = date.today()
sick_resp = await client.post(
"/api/v1/absences/quick-sick",
json={"start_date": str(today), "end_date": str(today)},
headers=bl_headers,
)
assert sick_resp.status_code in (200, 201), sick_resp.text
resp = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {bl_token}"},
)
body = resp.json()
me_entry = next((u for u in body["users"] if u["personnel_number"] == "0001"), None)
assert me_entry is not None
cats = {a["category"] for a in me_entry["absences_today"]}
assert "sick" in cats
# ── Tenant-Isolation ─────────────────────────────────────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def other_company_token(client: AsyncClient):
"""Zweite Firma mit eigenem Token + eigenem User mit personnel_number 9999."""
reg = await client.post("/api/v1/auth/register", json={
"company_name": "Other GmbH",
"first_name": "Other",
"last_name": "Boss",
"email": "other@boss.de",
"password": "Secret123",
})
assert reg.status_code == 201
headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
me = await client.get("/api/v1/auth/me", headers=headers)
user_id = me.json()["id"]
patch = await client.patch(
f"/api/v1/users/{user_id}",
json={"personnel_number": "9999"},
headers=headers,
)
assert patch.status_code == 200
rot = await client.post("/api/v1/companies/me/busylight-token/rotate", headers=headers)
assert rot.status_code == 200
return rot.json()["token"]
@pytest.mark.asyncio
async def test_tenant_isolation(client: AsyncClient, other_company_token, bl_token):
"""Token von Firma B liefert nur deren User keine User von Firma A."""
resp_b = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {other_company_token}"},
)
assert resp_b.status_code == 200
pns_b = {u["personnel_number"] for u in resp_b.json()["users"]}
assert "9999" in pns_b
assert "0001" not in pns_b
resp_a = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {bl_token}"},
)
pns_a = {u["personnel_number"] for u in resp_a.json()["users"]}
assert "0001" in pns_a
assert "9999" not in pns_a
# ── Token-Delete ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_delete_token_invalidates_pull(client: AsyncClient, bl_headers):
rot = await client.post("/api/v1/companies/me/busylight-token/rotate", headers=bl_headers)
fresh = rot.json()["token"]
pull_ok = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {fresh}"},
)
assert pull_ok.status_code == 200
dl = await client.delete("/api/v1/companies/me/busylight-token", headers=bl_headers)
assert dl.status_code == 204
pull_after = await client.get(
"/api/v1/busylight/users",
headers={"Authorization": f"Bearer {fresh}"},
)
assert pull_after.status_code == 401