Files
timemaster/backend/tests/test_public_stamp.py
T
patrick cead46c1e1 feat: Statischer firmenweiter QR-Code für mobiles Ein-/Ausstempeln
Mitarbeiter scannen einen am Eingang ausgehängten QR-Code mit dem Privat-Handy
(/stamp?t=<token>), melden sich per Personalnummer + PIN an und stempeln ein/aus.

Eigener öffentlicher Endpunkt-Pfad, da der Kiosk-PIN-Login Ed25519-Geräte-
Signaturen verlangt, die ein Privat-Handy nicht hat.

Backend:
- Company.public_stamp_enabled (opt-in, default OFF) + rotierbares
  public_stamp_token_hash (SHA-256) + created_at; Migration 0033
- Router /time/public: company/auth/action (slowapi-Limits, AuditLog)
- kiosk_auth_service.login_pin_public() reused PIN-Lockout, keyed auf
  (public:company_id, personnel_number)
- public_stamp_session_service: 120s Redis-Kurz-Session
- Admin-Token-Endpunkte in companies.py (GET/rotate/DELETE)

Frontend:
- Public-Route /stamp (PublicStampPage)
- Stempel-PIN-Verwaltung in ProfilePage (reused POST /users/{id}/kiosk-pin)
- QR-Generierung/Druck/Toggle in CompanySettingsPage

Sicherheit: schwächer als Kiosk (keine Geräte-Signatur/Nonce/IP-Whitelist),
bewusster BYOD-Komfort-Tradeoff; Schutz über PIN + Lockout + opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:58:38 +02:00

169 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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