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>
This commit is contained in:
2026-06-02 15:58:38 +02:00
parent 03d5fd6e2e
commit cead46c1e1
14 changed files with 1130 additions and 2 deletions
+168
View File
@@ -0,0 +1,168 @@
"""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