cead46c1e1
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>
169 lines
7.0 KiB
Python
169 lines
7.0 KiB
Python
"""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
|