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:
@@ -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
|
||||
Reference in New Issue
Block a user