Statt Hard-Delete (kollidiert mit Lohn-/Steuer-Aufbewahrung) werden bei
DSGVO-Löschanfrage die personenbezogenen Felder der bestehenden User-Zeile
überschrieben; referenzierte Daten (Zeit/Abwesenheit/Auszahlung/AuditLog)
bleiben entkoppelt erhalten.
- users.anonymized_at (Migration 0040); UserOut.anonymized_at.
- user_service.anonymize_user: überschreibt Name/E-Mail (eindeutig anon-<id>@deleted.local),
entfernt Auth-Material (Passwort/PIN/QR/NFC/TOTP/LDAP-DN), is_active=False, löscht Sessions,
AuditLog 'user_anonymized'. Personalnummer bleibt reserviert (kein Personenbezug mehr).
Selbst-Anonymisierung (400), Doppelanonymisierung/Reaktivierung anonymisierter Konten (409).
- POST /users/{id}/anonymize (COMPANY_ADMIN/SUPER_ADMIN, mandantengeprüft).
- UsersPage: "Anonymisieren"-Button mit Sicherheitsabfrage + "Anonymisiert"-Badge.
193/193 Tests grün. Deployed 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
USERS_URL = "/api/v1/users"
|
||
INVITE_URL = "/api/v1/users/invite"
|
||
|
||
|
||
def auth(tokens):
|
||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||
|
||
|
||
async def test_list_users_as_admin(client: AsyncClient, registered_user):
|
||
resp = await client.get(USERS_URL + "/", headers=auth(registered_user["tokens"]))
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert "total" in body
|
||
assert "items" in body
|
||
assert body["total"] >= 1
|
||
|
||
|
||
async def test_list_users_forbidden_for_employee(client: AsyncClient, registered_user):
|
||
# Invite an employee first
|
||
inv = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "employee@test.de",
|
||
"first_name": "Anna",
|
||
"last_name": "Arbeit",
|
||
"role": "EMPLOYEE",
|
||
})
|
||
assert inv.status_code == 201
|
||
|
||
# Employee tries to list users – should fail
|
||
# (We can't log in as employee here without accepting invite;
|
||
# so we test the role check via schema validation only)
|
||
assert inv.json()["role"] == "EMPLOYEE"
|
||
|
||
|
||
async def test_invite_user(client: AsyncClient, registered_user):
|
||
resp = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "newcolleague@test.de",
|
||
"first_name": "Birgit",
|
||
"last_name": "Neu",
|
||
"role": "MANAGER",
|
||
})
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["email"] == "newcolleague@test.de"
|
||
assert body["role"] == "MANAGER"
|
||
assert body["is_active"] is False # not yet accepted
|
||
|
||
|
||
async def test_invite_duplicate_email(client: AsyncClient, registered_user):
|
||
await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "dup@test.de", "first_name": "D", "last_name": "U", "role": "EMPLOYEE",
|
||
})
|
||
resp = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "dup@test.de", "first_name": "D", "last_name": "U", "role": "EMPLOYEE",
|
||
})
|
||
assert resp.status_code == 400
|
||
|
||
|
||
async def test_get_me(client: AsyncClient, registered_user):
|
||
resp = await client.get(USERS_URL + "/me", headers=auth(registered_user["tokens"]))
|
||
assert resp.status_code == 200
|
||
assert resp.json()["id"] == registered_user["user"]["id"]
|
||
|
||
|
||
async def test_update_user(client: AsyncClient, registered_user):
|
||
user_id = registered_user["user"]["id"]
|
||
resp = await client.patch(
|
||
f"{USERS_URL}/{user_id}",
|
||
headers=auth(registered_user["tokens"]),
|
||
json={"first_name": "Maximilian"},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["first_name"] == "Maximilian"
|
||
|
||
|
||
async def test_deactivate_and_reactivate(client: AsyncClient, registered_user):
|
||
# Invite a second user to deactivate
|
||
inv = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "temp@test.de", "first_name": "T", "last_name": "Emp", "role": "EMPLOYEE",
|
||
})
|
||
user_id = inv.json()["id"]
|
||
|
||
deact = await client.post(
|
||
f"{USERS_URL}/{user_id}/deactivate",
|
||
headers=auth(registered_user["tokens"]),
|
||
)
|
||
assert deact.status_code == 200
|
||
assert deact.json()["is_active"] is False
|
||
|
||
react = await client.post(
|
||
f"{USERS_URL}/{user_id}/reactivate",
|
||
headers=auth(registered_user["tokens"]),
|
||
)
|
||
assert react.status_code == 200
|
||
assert react.json()["is_active"] is True
|
||
|
||
|
||
async def test_cannot_deactivate_self(client: AsyncClient, registered_user):
|
||
user_id = registered_user["user"]["id"]
|
||
resp = await client.post(
|
||
f"{USERS_URL}/{user_id}/deactivate",
|
||
headers=auth(registered_user["tokens"]),
|
||
)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
async def test_set_kiosk_pin(client: AsyncClient, registered_user):
|
||
user_id = registered_user["user"]["id"]
|
||
resp = await client.post(
|
||
f"{USERS_URL}/{user_id}/kiosk-pin",
|
||
headers=auth(registered_user["tokens"]),
|
||
json={"pin": "1234"},
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
|
||
async def test_kiosk_pin_must_be_numeric(client: AsyncClient, registered_user):
|
||
user_id = registered_user["user"]["id"]
|
||
resp = await client.post(
|
||
f"{USERS_URL}/{user_id}/kiosk-pin",
|
||
headers=auth(registered_user["tokens"]),
|
||
json={"pin": "abcd"},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
async def test_anonymize_user(client: AsyncClient, registered_user):
|
||
"""DSGVO Art. 17: Anonymisierung entfernt den Personenbezug, Konto wird inaktiv."""
|
||
inv = await client.post(INVITE_URL, headers=auth(registered_user["tokens"]), json={
|
||
"email": "gdpr@test.de", "first_name": "Max", "last_name": "Muster",
|
||
"role": "EMPLOYEE", "initial_password": "Secret123",
|
||
})
|
||
user_id = inv.json()["id"]
|
||
|
||
resp = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["anonymized_at"] is not None
|
||
assert body["first_name"] == "Anonymisiert"
|
||
assert body["email"] == f"anon-{user_id}@deleted.local"
|
||
assert body["is_active"] is False
|
||
|
||
# Login mit alter E-Mail unmöglich
|
||
login = await client.post("/api/v1/auth/login", json={"email": "gdpr@test.de", "password": "Secret123"})
|
||
assert login.status_code == 401
|
||
|
||
# Reaktivierung eines anonymisierten Kontos wird abgelehnt
|
||
react = await client.post(f"{USERS_URL}/{user_id}/reactivate", headers=auth(registered_user["tokens"]))
|
||
assert react.status_code == 409
|
||
|
||
# Erneutes Anonymisieren wird abgelehnt
|
||
again = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||
assert again.status_code == 409
|
||
|
||
|
||
async def test_cannot_anonymize_self(client: AsyncClient, registered_user):
|
||
user_id = registered_user["user"]["id"]
|
||
resp = await client.post(f"{USERS_URL}/{user_id}/anonymize", headers=auth(registered_user["tokens"]))
|
||
assert resp.status_code == 400
|