Files
denya-onecare/tests/test_p0_auth_admin_batch.py
T
root 3ae1062d65 P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed
- Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles,
  self-lockout + reference guards)
- Unify role model in app/core/roles.py; reject unknown roles at creation and
  at login/JWT validation; startup normalizes unambiguous legacy aliases
- Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable)
- WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset;
  GET handshake uses constant-time verify token (403 on mismatch)
- GET /api/whatsapp/mock-log now requires auth
- Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML,
  HSTS behind TLS
- Pagination: limit alias for page_size, hard cap enforced, both -> 422
2026-09-08 10:36:16 +00:00

541 lines
21 KiB
Python

"""P0 security batch — admin user management, unified role model, login rate
limiting, WhatsApp webhook secret, mock-log auth, security headers, pagination.
Each item in the P0 hardening batch has an executable behavioral test here
(plus the register-removal tests living in test_p0_hardening.py).
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from app.core.config import settings
from app.core.database import async_session_factory
from app.core.security import hash_password
from app.models.user import User
from app.services.seed import normalize_legacy_user_roles
pytestmark = pytest.mark.asyncio
# ── helpers ───────────────────────────────────────────────────────────
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post("/api/auth/login", json={"email": email, "password": password})
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def _insert_user(email: str, role: str, *, active: bool = True) -> int:
"""Insert a user row directly (bypasses API role validation) — used to
simulate legacy bootstrap/registration rows in the DB."""
async with async_session_factory() as session:
user = User(
email=email,
password_hash=hash_password("denya123"),
full_name=f"Legacy {email}",
role=role,
active=active,
)
session.add(user)
await session.commit()
return user.id
async def _normalize_roles() -> None:
async with async_session_factory() as session:
await normalize_legacy_user_roles(session)
await session.commit()
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "P0 Batch Test",
"reported_via": "walk-in",
"description": "p0 batch ticket",
**overrides,
}
resp = await client.post("/api/tickets", json=payload, headers=_auth(token))
assert resp.status_code == 201, resp.text
return resp.json()
# ── Item 2/3: admin user management ───────────────────────────────────
async def test_create_user_requires_admin(client: AsyncClient):
token = await _login(client, email="bella@denya.com") # CS Rep
resp = await client.post(
"/api/auth/users",
json={"email": "x@example.com", "password": "password1", "full_name": "X", "role": "CS Rep"},
headers=_auth(token),
)
assert resp.status_code == 403
async def test_create_user_requires_auth(client: AsyncClient):
resp = await client.post(
"/api/auth/users",
json={"email": "x@example.com", "password": "password1", "full_name": "X", "role": "CS Rep"},
)
assert resp.status_code == 401
async def test_admin_creates_user_with_forced_role(client: AsyncClient):
token = await _login(client) # Admin/Wahab
resp = await client.post(
"/api/auth/users",
json={"email": "New.Tech@Example.com", "password": "password1", "full_name": "New Tech", "role": "Tech"},
headers=_auth(token),
)
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["role"] == "Tech"
assert created["active"] is True
assert created["email"] == "new.tech@example.com" # normalized lower-case
# The new user can actually log in with their forced role.
login = await client.post(
"/api/auth/login", json={"email": "new.tech@example.com", "password": "password1"}
)
assert login.status_code == 200
me = await client.get("/api/auth/me", headers=_auth(login.json()["access_token"]))
assert me.json()["role"] == "Tech"
async def test_admin_create_user_rejects_unknown_roles(client: AsyncClient):
"""Unified role model: junk/legacy roles cannot be minted at creation."""
token = await _login(client)
for role in ("admin", "superadmin", "technician", "root"):
resp = await client.post(
"/api/auth/users",
json={"email": f"{role.strip().lower()}@example.com", "password": "password1", "full_name": "X", "role": role},
headers=_auth(token),
)
assert resp.status_code == 422, (role, resp.text)
async def test_admin_create_user_duplicate_email_conflict(client: AsyncClient):
token = await _login(client)
payload = {"email": "dupe2@example.com", "password": "password1", "full_name": "D", "role": "CS Rep"}
assert (await client.post("/api/auth/users", json=payload, headers=_auth(token))).status_code == 201
resp = await client.post("/api/auth/users", json=payload, headers=_auth(token))
assert resp.status_code == 409
async def test_patch_user_role_change(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"role": "CS Rep"},
headers=_auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "CS Rep"
assert resp.json()["active"] is True
async def test_patch_user_rejects_unknown_role(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"role": "superadmin"},
headers=_auth(token),
)
assert resp.status_code == 422, resp.text
async def test_patch_deactivate_blocks_login(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
resp = await client.patch(
f"/api/auth/users/{tech['id']}",
json={"active": False},
headers=_auth(token),
)
assert resp.status_code == 200
assert resp.json()["active"] is False
login = await client.post(
"/api/auth/login", json={"email": tech["email"], "password": "denya123"}
)
assert login.status_code == 401
async def test_admin_cannot_modify_own_account(client: AsyncClient):
token = await _login(client) # wahab
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
resp = await client.patch(
f"/api/auth/users/{me['id']}", json={"active": False}, headers=_auth(token)
)
assert resp.status_code == 400
async def test_admin_can_demote_other_admin_but_not_self(client: AsyncClient):
"""An admin can manage the other admin seat, but self-removal stays blocked,
so at least one canonical admin always remains (structural invariant)."""
token = await _login(client) # wahab
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
jerome = next(u for u in users if u["role"] == "Admin/Jerome")
wahab = next(u for u in users if u["role"] == "Admin/Wahab")
# Demote the OTHER admin → allowed, wahab is still the acting admin.
resp = await client.patch(
f"/api/auth/users/{jerome['id']}", json={"role": "CEO"}, headers=_auth(token)
)
assert resp.status_code == 200, resp.text
# Demoting/deactivating yourself is always rejected.
resp = await client.patch(
f"/api/auth/users/{wahab['id']}", json={"active": False}, headers=_auth(token)
)
assert resp.status_code == 400
async def test_delete_user_admin_only_and_works(client: AsyncClient):
admin_token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(admin_token))).json()
tech = next(u for u in users if u["role"] == "Tech")
# Non-admin cannot delete.
cs_token = await _login(client, email="bella@denya.com")
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(cs_token))
assert resp.status_code == 403
# Admin deletes → 204, user gone, login fails.
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(admin_token))
assert resp.status_code == 204
login = await client.post(
"/api/auth/login", json={"email": tech["email"], "password": "denya123"}
)
assert login.status_code == 401
async def test_delete_user_referenced_by_ticket_is_409(client: AsyncClient):
token = await _login(client)
users = (await client.get("/api/auth/users", headers=_auth(token))).json()
tech = next(u for u in users if u["role"] == "Tech")
ticket = await _create_ticket(client, token)
resp = await client.patch(
f"/api/tickets/{ticket['id']}",
json={"assigned_to": tech["id"]},
headers=_auth(token),
)
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/api/auth/users/{tech['id']}", headers=_auth(token))
assert resp.status_code == 409
assert "related" in resp.json()["detail"].lower()
async def test_admin_cannot_delete_self(client: AsyncClient):
token = await _login(client)
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
resp = await client.delete(f"/api/auth/users/{me['id']}", headers=_auth(token))
assert resp.status_code == 400
# ── Item 3: unified role model — legacy rows & JWT validation ─────────
async def test_legacy_alias_role_normalized_at_startup(client: AsyncClient):
"""A legacy 'technician' row is mapped onto canonical 'Tech' at startup."""
await _insert_user("legacy-tech@example.com", "technician")
await _normalize_roles() # what lifespan does each boot
login = await client.post(
"/api/auth/login", json={"email": "legacy-tech@example.com", "password": "denya123"}
)
assert login.status_code == 200, login.text
me = await client.get("/api/auth/me", headers=_auth(login.json()["access_token"]))
assert me.json()["role"] == "Tech"
async def test_login_rejects_unknown_legacy_role_fail_closed(client: AsyncClient):
"""Lowercase 'superadmin' rows (mintable by the old open register) cannot
log in — fail closed, never granted admin powers."""
await _insert_user("legacy-admin@example.com", "superadmin")
# NOTE: no normalize call — the row is exactly what the live DB holds today.
login = await client.post(
"/api/auth/login", json={"email": "legacy-admin@example.com", "password": "denya123"}
)
assert login.status_code == 401
assert "role" in login.json()["detail"].lower()
async def test_jwt_validation_rejects_unknown_role(client: AsyncClient):
"""A token for a user whose row later becomes junk-role must fail closed."""
token = await _login(client) # wahab is a canonical admin at token time
me = (await client.get("/api/auth/me", headers=_auth(token))).json()
# Simulate a legacy DB row flip to a non-canonical role.
async with async_session_factory() as session:
from sqlalchemy import select
user = (await session.execute(select(User).where(User.id == me["id"]))).scalar_one()
user.role = "admin"
await session.commit()
resp = await client.get("/api/auth/me", headers=_auth(token))
assert resp.status_code == 401
async def test_normalize_does_not_map_ambiguous_admin_alias(client: AsyncClient):
"""normalize_legacy_user_roles leaves identity-ambiguous 'admin' rows for
operator remediation instead of guessing a canonical admin."""
await _insert_user("legacy-admin2@example.com", "admin")
await _normalize_roles()
async with async_session_factory() as session:
from sqlalchemy import select
user = (
await session.execute(select(User).where(User.email == "legacy-admin2@example.com"))
).scalar_one()
assert user.role == "admin"
async def test_admin_only_rbac_gate(client: AsyncClient):
"""Canonical admins pass /api/auth/admin-only; everyone else 403."""
wahab = await _login(client)
assert (await client.get("/api/auth/admin-only", headers=_auth(wahab))).status_code == 200
bella = await _login(client, email="bella@denya.com")
assert (await client.get("/api/auth/admin-only", headers=_auth(bella))).status_code == 403
# ── Item 4: login rate limiting ───────────────────────────────────────
async def test_login_rate_limited_after_five_failures(client: AsyncClient):
email, password = "rate-limited@example.com", "denya123"
# Make sure the account exists with a valid password.
token = await _login(client)
await client.post(
"/api/auth/users",
json={"email": email, "password": password, "full_name": "Rate", "role": "CS Rep"},
headers=_auth(token),
)
for _ in range(5):
resp = await client.post(
"/api/auth/login", json={"email": email, "password": "wrong-password"}
)
assert resp.status_code == 401
# 6th attempt — even with the CORRECT password — is throttled.
resp = await client.post(
"/api/auth/login", json={"email": email, "password": password}
)
assert resp.status_code == 429, resp.text
async def test_rate_limit_is_per_email(client: AsyncClient):
"""Failures for one account never lock out another account."""
token = await _login(client)
for email in ("victim@example.com", "other@example.com"):
await client.post(
"/api/auth/users",
json={"email": email, "password": "password1", "full_name": "U", "role": "CS Rep"},
headers=_auth(token),
)
for _ in range(6):
resp = await client.post(
"/api/auth/login", json={"email": "victim@example.com", "password": "bad"}
)
assert resp.status_code in (401, 429)
# Unaffected account still logs in fine.
resp = await client.post(
"/api/auth/login", json={"email": "other@example.com", "password": "password1"}
)
assert resp.status_code == 200, resp.text
async def test_rate_limit_window_expires(client: AsyncClient, monkeypatch):
"""After the 15-minute window passes, the account can log in again."""
import app.core.ratelimit as ratelimit_mod
import time as _time
email, password = "window@example.com", "password1"
token = await _login(client)
await client.post(
"/api/auth/users",
json={"email": email, "password": password, "full_name": "W", "role": "CS Rep"},
headers=_auth(token),
)
# Pin the limiter clock so the window can be fast-forwarded deterministically.
clock = {"now": _time.time()}
monkeypatch.setattr(ratelimit_mod, "_now", lambda: clock["now"])
for _ in range(5):
await client.post("/api/auth/login", json={"email": email, "password": "bad"})
blocked = await client.post("/api/auth/login", json={"email": email, "password": password})
assert blocked.status_code == 429, blocked.text
clock["now"] += settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS + 1
resp = await client.post("/api/auth/login", json={"email": email, "password": password})
assert resp.status_code == 200, resp.text
# ── Item 5: WhatsApp webhook secret (fail closed) ─────────────────────
WEBHOOK_BODY = {
"object": "whatsapp_business_account",
"entry": [
{
"id": "1",
"changes": [
{
"id": "wamid.1",
"message": {"from": "+233000000000", "id": "wamid.1", "text": {"text": "AC leaking"}},
}
],
}
],
}
async def test_webhook_fail_closed_when_env_unset(client: AsyncClient):
"""WHATSAPP_WEBHOOK_SECRET unset ⇒ every message rejected (403)."""
assert settings.WHATSAPP_WEBHOOK_SECRET == "" # test env default is unset
resp = await client.post("/api/whatsapp/webhook", json=WEBHOOK_BODY)
assert resp.status_code == 403
assert "not configured" in resp.json()["detail"].lower()
async def test_webhook_rejects_missing_or_wrong_secret(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
resp = await client.post("/api/whatsapp/webhook", json=WEBHOOK_BODY)
assert resp.status_code == 403
resp = await client.post(
"/api/whatsapp/webhook", json=WEBHOOK_BODY, headers={"X-Webhook-Secret": "wrong"}
)
assert resp.status_code == 403
async def test_webhook_accepts_valid_secret_and_creates_ticket(client: AsyncClient, monkeypatch):
from app.routers import whatsapp as whatsapp_router
async def _fake_reply(to_phone: str, text: str):
from app.schemas.whatsapp import WhatsAppReplyResponse
return WhatsAppReplyResponse(success=True, message="sent")
monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret")
monkeypatch.setattr(whatsapp_router, "send_whatsapp_reply", _fake_reply)
resp = await client.post(
"/api/whatsapp/webhook",
json=WEBHOOK_BODY,
headers={"X-Webhook-Secret": "test-webhook-secret"},
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "processed"
assert data["ticket_number"].startswith("PAV-")
# The message is logged and visible to an authenticated mock-log caller.
token = await _login(client)
log = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert log.status_code == 200
assert len(log.json()) == 1
assert log.json()[0]["ticket_number"] == data["ticket_number"]
async def test_webhook_verify_token_mismatch_403(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_VERIFY_TOKEN", "verify-me")
resp = await client.get(
"/api/whatsapp/webhook",
params={"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "1234"},
)
assert resp.status_code == 403
async def test_webhook_verify_token_match_returns_challenge(client: AsyncClient, monkeypatch):
monkeypatch.setattr(settings, "WHATSAPP_VERIFY_TOKEN", "verify-me")
resp = await client.get(
"/api/whatsapp/webhook",
params={"hub.mode": "subscribe", "hub.verify_token": "verify-me", "hub.challenge": "1234"},
)
assert resp.status_code == 200
assert resp.json() == {"challenge": "1234"}
# ── Item 6: mock-log requires auth ────────────────────────────────────
async def test_mock_log_requires_auth(client: AsyncClient):
resp = await client.get("/api/whatsapp/mock-log")
assert resp.status_code == 401, resp.text
token = await _login(client)
resp = await client.get("/api/whatsapp/mock-log", headers=_auth(token))
assert resp.status_code == 200
assert resp.json() == []
# ── Item 7: security headers ──────────────────────────────────────────
async def test_security_headers_on_html_page(client: AsyncClient):
resp = await client.get("/login")
assert resp.status_code == 200
assert resp.headers["x-frame-options"] == "DENY"
assert resp.headers["x-content-type-options"] == "nosniff"
assert "content-security-policy" in resp.headers
assert "default-src 'self'" in resp.headers["content-security-policy"]
async def test_csp_only_on_html_not_json_api(client: AsyncClient):
resp = await client.get("/api/tickets")
assert resp.status_code == 200
assert "content-type" in resp.headers and resp.headers["content-type"].startswith("application/json")
assert "content-security-policy" not in resp.headers
# Frame/type hardening headers apply everywhere.
assert resp.headers["x-frame-options"] == "DENY"
assert resp.headers["x-content-type-options"] == "nosniff"
async def test_hsts_only_when_tls_terminates(client: AsyncClient):
plain = await client.get("/login")
assert "strict-transport-security" not in plain.headers
tls = await client.get("/login", headers={"X-Forwarded-Proto": "https"})
assert tls.headers["strict-transport-security"] == "max-age=31536000; includeSubDomains"
# ── Item 8: pagination (page/limit) and sane max page size ────────────
async def test_limit_alias_over_cap_rejected(client: AsyncClient, seed_tickets):
await seed_tickets(10)
resp = await client.get("/api/tickets", params={"limit": 500})
assert resp.status_code == 422
async def test_limit_alias_paginates(client: AsyncClient, seed_tickets):
await seed_tickets(14)
resp = await client.get("/api/tickets", params={"page": 2, "limit": 5})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 14
assert len(data["items"]) == 5
assert data["page_size"] == 5
assert data["page"] == 2
async def test_page_beyond_last_returns_empty_with_total(client: AsyncClient, seed_tickets):
await seed_tickets(7)
resp = await client.get("/api/tickets", params={"page": 999, "page_size": 10})
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 7
async def test_page_size_and_limit_conflict_is_422(client: AsyncClient, seed_tickets):
await seed_tickets(3)
resp = await client.get("/api/tickets", params={"page_size": 10, "limit": 20})
assert resp.status_code == 422