Files
denya-onecare/tests/test_p0_hardening.py
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

133 lines
4.2 KiB
Python

"""P0 hardening regression tests (HARDENING.md P0.1 / P0.2 / P0.3).
Covers:
- P0.3: self-registration is REMOVED — POST /api/auth/register returns 404 and
no public path can mint a user at all (users are admin-created only).
- P0.1: app fails to import/boot with placeholder or missing SECRET_KEY
- P0.2: app fails to boot with CORS_ORIGINS="*"
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from httpx import AsyncClient
REPO_ROOT = Path(__file__).resolve().parent.parent
pytestmark = pytest.mark.asyncio
# ── P0.3: self-registration is removed entirely ───────────────────────
async def test_register_endpoint_is_removed(client: AsyncClient):
"""A raw unauthenticated register call must 404 — no public signup path."""
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": "Admin/Jerome",
},
)
assert resp.status_code == 404, resp.text
async def test_register_endpoint_removed_regardless_of_role(client: AsyncClient):
"""Attempts to mint privileged (or any) roles via register all 404."""
for role in ("Admin/Jerome", "Admin/Wahab", "admin", "superadmin", "CS Rep"):
resp = await client.post(
"/api/auth/register",
json={
"email": f"attacker-{role.lower().replace('/', '-')}@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": role,
},
)
assert resp.status_code == 404, (role, resp.text)
async def test_register_does_not_create_user(client: AsyncClient):
"""No user row is ever created through the removed register endpoint."""
await client.post(
"/api/auth/register",
json={
"email": "ghost@example.com",
"password": "Sup3rSecret!",
"full_name": "Ghost",
},
)
# The ghost account must not be able to log in.
resp = await client.post(
"/api/auth/login",
json={"email": "ghost@example.com", "password": "Sup3rSecret!"},
)
assert resp.status_code == 401, resp.text
def _boot_with_env(env_overrides: dict[str, str]) -> subprocess.CompletedProcess:
"""Try importing app.main in a subprocess with the given env; the import
must fail (non-zero) when fail-closed validation trips."""
env = os.environ.copy()
env["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
env.pop("SECRET_KEY", None)
env.pop("CORS_ORIGINS", None)
env.update(env_overrides)
script = (
"import sys; sys.path.insert(0, ''); "
"import app.main" # noqa
)
return subprocess.run(
[sys.executable, "-c", script],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def test_boot_fails_with_placeholder_secret():
result = _boot_with_env({"SECRET_KEY": "change-me-in-production"})
assert result.returncode != 0, "app booted with placeholder SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_short_secret():
result = _boot_with_env({"SECRET_KEY": "tooshort"})
assert result.returncode != 0, "app booted with a <32-char SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_without_secret():
result = _boot_with_env({"SECRET_KEY": ""})
assert result.returncode != 0, "app booted without a SECRET_KEY!"
assert "SECRET_KEY" in result.stderr
def test_boot_fails_with_wildcard_cors():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "*",
}
)
assert result.returncode != 0, "app booted with CORS_ORIGINS=* !"
assert "CORS_ORIGINS" in result.stderr
def test_boot_succeeds_with_valid_env():
result = _boot_with_env(
{
"SECRET_KEY": "test-secret-key-not-for-production-0123456789abcdef",
"CORS_ORIGINS": "http://test",
}
)
assert result.returncode == 0, result.stderr