"""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