CT115 (Mumuni relay #747) — two live-instance defects after PR #12:
1. GET /api/whatsapp/mock-log 500'd with a valid admin token:
'no such column: whatsapp_log.message_text'. The model gained
message_text/wa_message_id/ticket_number (and dropped command) in
4afdc36 with no migration, so legacy DBs keep the (command, ...) shape.
ensure_legacy_schema (startup, app/main.py) now adds the three missing
columns idempotently and backfills legacy command bodies into
message_text before dropping the obsolete NOT NULL command column, so
both the mock-log read path and the ORM write path work on healed DBs.
New producer/consumer regression (tests/test_whatsapp_log_legacy_heal.py)
reproduces the exact OperationalError, then asserts 200 + data.
2. ROLE_ALIASES gap: underscore legacy roles (cs_rep, cs_manager,
fm_dispatcher) were not mapped, so normalize_legacy_user_roles could not
converge rows like user 18 (test@denya.com, role 'cs_rep') and the
frontend stranded them on /tickets. Added the underscore aliases; tests
assert normalize_role('cs_rep') == 'CS Rep' and a cs_rep row converges
and authenticates.
648 lines
26 KiB
Python
648 lines
26 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_emails, 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 _normalize_emails() -> None:
|
|
async with async_session_factory() as session:
|
|
await normalize_legacy_user_emails(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_underscore_legacy_aliases_normalize_to_canonical():
|
|
"""Open-register rows can carry underscore role forms ('cs_rep',
|
|
'cs_manager', 'fm_dispatcher') — the exact gap Mumuni relay #747 found on
|
|
user 18 (test@denya.com). Each must map onto its canonical role so startup
|
|
normalization can converge the row instead of stranding it on /tickets."""
|
|
from app.core.roles import normalize_role
|
|
|
|
assert normalize_role("cs_rep") == "CS Rep"
|
|
assert normalize_role("cs_manager") == "CS Manager"
|
|
assert normalize_role("fm_dispatcher") == "FM Dispatcher"
|
|
# Matching is case/whitespace tolerant, like the space-form aliases.
|
|
assert normalize_role(" CS_REP ") == "CS Rep"
|
|
assert normalize_role("cs_rep") is not None # known, not fail-closed
|
|
|
|
|
|
async def test_legacy_cs_rep_row_converges_and_authenticates(client: AsyncClient):
|
|
"""A 'cs_rep' row (user 18 test@denya.com shape) is converged to canonical
|
|
'CS Rep' by the startup self-heal and can log in again (no fail-closed
|
|
denial, and the frontend CS nav sees the canonical role)."""
|
|
await _insert_user("cs-rep-legacy@example.com", "cs_rep")
|
|
await _normalize_roles() # what lifespan does each boot
|
|
|
|
async with async_session_factory() as session:
|
|
from sqlalchemy import select
|
|
user = (
|
|
await session.execute(select(User).where(User.email == "cs-rep-legacy@example.com"))
|
|
).scalar_one()
|
|
assert user.role == "CS Rep"
|
|
|
|
login = await client.post(
|
|
"/api/auth/login", json={"email": "cs-rep-legacy@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"] == "CS Rep"
|
|
|
|
|
|
# ── P0 email normalization (legacy mixed-case rows) ───────────────────
|
|
async def test_legacy_mixed_case_email_migrated_and_authenticates(client: AsyncClient):
|
|
"""A legacy row whose email was stored verbatim in mixed case (the old open
|
|
register) is lowercased by the startup self-heal and still authenticates."""
|
|
await _insert_user("DemoUser@Example.com", "Tech")
|
|
await _normalize_emails() # what lifespan does each boot
|
|
|
|
async with async_session_factory() as session:
|
|
from sqlalchemy import select
|
|
user = (
|
|
await session.execute(select(User).where(User.email == "demouser@example.com"))
|
|
).scalar_one()
|
|
assert user.email == "demouser@example.com"
|
|
|
|
for variant in ("demouser@example.com", "DemoUser@Example.com"):
|
|
resp = await client.post(
|
|
"/api/auth/login", json={"email": variant, "password": "denya123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_login_matches_legacy_mixed_case_email_before_migration(client: AsyncClient):
|
|
"""Login compares on the normalized form, so an un-migrated mixed-case row
|
|
is still matched by its lowercase login (no hard dependency on the
|
|
self-heal having run)."""
|
|
await _insert_user("DemoUser@Example.com", "Tech")
|
|
resp = await client.post(
|
|
"/api/auth/login", json={"email": "demouser@example.com", "password": "denya123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_legacy_email_normalization_is_idempotent(client: AsyncClient):
|
|
"""The startup self-heal rewrites once and no-ops on subsequent boots."""
|
|
await _insert_user("DemoUser@Example.com", "Tech")
|
|
async with async_session_factory() as session:
|
|
first = await normalize_legacy_user_emails(session)
|
|
await session.commit()
|
|
async with async_session_factory() as session:
|
|
second = await normalize_legacy_user_emails(session)
|
|
await session.commit()
|
|
assert first == 1
|
|
assert second == 0
|
|
|
|
|
|
async def test_create_user_rejects_case_variant_of_legacy_email(client: AsyncClient):
|
|
"""The admin create-user duplicate check compares on the normalized form:
|
|
creating a case-variant of a legacy mixed-case row returns 409, not 201."""
|
|
await _insert_user("DemoUser@Example.com", "Tech")
|
|
token = await _login(client)
|
|
resp = await client.post(
|
|
"/api/auth/users",
|
|
json={
|
|
"email": "demouser@example.com",
|
|
"password": "password1",
|
|
"full_name": "X",
|
|
"role": "Tech",
|
|
},
|
|
headers=_auth(token),
|
|
)
|
|
assert resp.status_code == 409, resp.text
|
|
|
|
|
|
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 time as _time
|
|
|
|
import app.core.ratelimit as ratelimit_mod
|
|
|
|
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
|