- 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
133 lines
4.2 KiB
Python
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
|