feat(auth): superadmin user-management page + CRUD

/admin/users page and /api/admin/users/* JSON CRUD endpoints, all behind
require_role("superadmin"). Temp passwords are returned once on create/reset
and never stored in plaintext. Admins get 403; password_hash is never leaked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:42:05 +00:00
parent 41ab900c33
commit bff9a4af4a
4 changed files with 297 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
# tests/test_operator_users.py
import uuid
from tests.conftest import wire_operator_auth
from backend.operator_auth import create_operator, make_operator_cookie, COOKIE_NAME
from backend.models import OperatorUser
def _login_as(client, user):
client.cookies.set(COOKIE_NAME, make_operator_cookie(user.id))
def test_admin_cannot_reach_user_management(client, db_session, monkeypatch):
admin, _ = create_operator(db_session, "admin@x.com", "Admin", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, admin)
assert client.get("/admin/users", follow_redirects=False).status_code == 403
def test_superadmin_sees_user_management(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.get("/admin/users", follow_redirects=False).status_code == 200
def test_superadmin_lists_users_json(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.get("/api/admin/users")
assert r.status_code == 200
emails = [u["email"] for u in r.json()["users"]]
assert "su@x.com" in emails
assert all("password_hash" not in u for u in r.json()["users"]) # never leak hashes
def test_create_user_returns_temp_once(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post("/api/admin/users",
json={"email": "dad@x.com", "name": "Dad", "role": "admin"})
assert r.status_code == 200
assert len(r.json()["password"]) >= 12
made = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
assert made.must_change_password is True
def test_reset_password_returns_temp_once(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{target.id}/reset-password")
assert r.status_code == 200 and len(r.json()["password"]) >= 12
db_session.refresh(target)
assert target.must_change_password is True
def test_disable_and_enable(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 200
db_session.refresh(target); assert target.active is False
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 200
db_session.refresh(target); assert target.active is True
def test_change_role(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"})
assert r.status_code == 200
db_session.refresh(target); assert target.role == "superadmin"
def test_admin_cannot_reach_json_endpoints(client, db_session, monkeypatch):
admin, _ = create_operator(db_session, "a@x.com", "A", "admin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, admin)
assert client.get("/api/admin/users").status_code == 403
assert client.post("/api/admin/users", json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 403
assert client.post(f"/api/admin/users/{target.id}/reset-password").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/disable").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/enable").status_code == 403
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "superadmin"}).status_code == 403
def test_cannot_disable_own_account(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{su.id}/disable")
assert r.status_code == 400
db_session.refresh(su)
assert su.active is True
def test_cannot_change_own_role(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
r = client.post(f"/api/admin/users/{su.id}/role", json={"role": "admin"})
assert r.status_code == 400
db_session.refresh(su)
assert su.role == "superadmin"
def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch):
su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456")
target, _ = create_operator(db_session, "t@x.com", "T", "admin", password="pw-123456")
wire_operator_auth(monkeypatch, db_session, enabled=True)
_login_as(client, su)
assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400
assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400