"""Tests für das öffentliche QR-Stempeln (Personalnummer + PIN, statischer QR). Benötigt Redis (PIN-Lockout + Kurz-Session) – läuft auf dem Server. """ import pytest import pytest_asyncio from httpx import AsyncClient async def _register(client: AsyncClient, company: str, email: str, pin: str | None, personnel: str = "0001", enable: bool = True): """Registriert Firma+Admin, setzt Personalnr/PIN, aktiviert QR-Stempeln, rotiert das Token. Gibt dict mit headers/token/personnel/pin zurück.""" reg = await client.post("/api/v1/auth/register", json={ "company_name": company, "first_name": "QR", "last_name": "Admin", "email": email, "password": "Secret123", }) assert reg.status_code == 201, reg.text headers = {"Authorization": f"Bearer {reg.json()['access_token']}"} me = await client.get("/api/v1/auth/me", headers=headers) user_id = me.json()["id"] pr = await client.patch(f"/api/v1/users/{user_id}", json={"personnel_number": personnel}, headers=headers) assert pr.status_code == 200, pr.text if pin is not None: pr = await client.post(f"/api/v1/users/{user_id}/kiosk-pin", json={"pin": pin}, headers=headers) assert pr.status_code == 200, pr.text upd = await client.patch("/api/v1/companies/me", json={"public_stamp_enabled": enable}, headers=headers) assert upd.status_code == 200, upd.text rot = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=headers) assert rot.status_code == 200, rot.text return {"headers": headers, "user_id": user_id, "token": rot.json()["token"], "personnel": personnel, "pin": pin} @pytest_asyncio.fixture(scope="session", loop_scope="session") async def ps(client: AsyncClient): return await _register(client, "QR Stamp GmbH", "qr@stamp.de", "1234") # ── Token-Auflösung ────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_company_info_resolves(client: AsyncClient, ps): resp = await client.get(f"/api/v1/time/public/company?t={ps['token']}") assert resp.status_code == 200 body = resp.json() assert body["company_name"] == "QR Stamp GmbH" assert body["enabled"] is True @pytest.mark.asyncio async def test_company_info_unknown_token_404(client: AsyncClient): resp = await client.get("/api/v1/time/public/company?t=totally-invalid-token-xyz") assert resp.status_code == 404 @pytest.mark.asyncio async def test_rotate_returns_url_once(client: AsyncClient, ps): resp = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=ps["headers"]) assert resp.status_code == 200 body = resp.json() assert "/stamp?t=" in body["public_url"] assert len(body["token"]) >= 32 # neues Token → ps['token'] (altes) ungültig old = await client.get(f"/api/v1/time/public/company?t={ps['token']}") assert old.status_code == 404 # ps-Fixture aktualisieren, damit Folge-Tests das gültige Token nutzen ps["token"] = body["token"] # ── Auth ───────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_auth_success(client: AsyncClient, ps): resp = await client.post("/api/v1/time/public/auth", json={ "token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"], }) assert resp.status_code == 200, resp.text body = resp.json() assert body["session_token"] assert body["expires_in_seconds"] > 0 assert body["open"] is False @pytest.mark.asyncio async def test_auth_wrong_pin_401(client: AsyncClient, ps): resp = await client.post("/api/v1/time/public/auth", json={ "token": ps["token"], "personnel_number": ps["personnel"], "pin": "9999", }) assert resp.status_code == 401 @pytest.mark.asyncio async def test_auth_disabled_company_403(client: AsyncClient): setup = await _register(client, "QR Disabled GmbH", "qr@disabled.de", "1234", personnel="0002", enable=False) resp = await client.post("/api/v1/time/public/auth", json={ "token": setup["token"], "personnel_number": "0002", "pin": "1234", }) assert resp.status_code == 403 @pytest.mark.asyncio async def test_auth_lockout_after_failures(client: AsyncClient): """5 Fehlversuche (auch bei unbekannter Personalnr.) → 429 Lockout.""" setup = await _register(client, "QR Lockout GmbH", "qr@lockout.de", "1234", personnel="0003") codes = [] for _ in range(6): r = await client.post("/api/v1/time/public/auth", json={ "token": setup["token"], "personnel_number": "999999", "pin": "0000", }) codes.append(r.status_code) assert 429 in codes # Sperre greift # ── Stempel-Aktionen ───────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_stamp_in_and_out(client: AsyncClient, ps): auth = await client.post("/api/v1/time/public/auth", json={ "token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"], }) assert auth.status_code == 200, auth.text session = auth.json()["session_token"] already_open = auth.json()["open"] if not already_open: sin = await client.post("/api/v1/time/public/action", json={"session_token": session, "action": "in"}) assert sin.status_code == 200, sin.text assert sin.json()["open"] is True sout = await client.post("/api/v1/time/public/action", json={"session_token": session, "action": "out"}) assert sout.status_code == 200, sout.text assert sout.json()["open"] is False @pytest.mark.asyncio async def test_action_invalid_session_401(client: AsyncClient): resp = await client.post("/api/v1/time/public/action", json={ "session_token": "00000000-0000-0000-0000-000000000000", "action": "in", }) assert resp.status_code == 401 # ── Token-Delete ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_delete_token_invalidates(client: AsyncClient): setup = await _register(client, "QR Delete GmbH", "qr@delete.de", "1234", personnel="0004") dl = await client.delete("/api/v1/companies/me/public-stamp-token", headers=setup["headers"]) assert dl.status_code == 204 resp = await client.get(f"/api/v1/time/public/company?t={setup['token']}") assert resp.status_code == 404