Compare commits

..
Author SHA1 Message Date
Mumuni (Syslog Code Agent) 3ec09470ff fix: P0 hardening — fail-closed SECRET_KEY, locked CORS, role-safe registration (HARDENING.md P0.1/P0.2/P0.3)
P0.1 — fail-closed secrets:
- config.py: no default SECRET_KEY; refuses to boot when unset, a known
  placeholder, or <32 chars. Generate with: openssl rand -hex 32.
- docker-compose.yml: literal secrets removed; runtime env now comes from
  a git-ignored .env via env_file. .env.example added as template.
- .gitignore already covers .env (verified).

P0.2 — locked CORS:
- main.py: CORS_ORIGINS must be an explicit comma-separated allow-list.
  '*' or an empty value refuses to boot (was: silently ['*'] with
  allow_credentials=True).

P0.3 — role-safe registration:
- services/auth.py: client-supplied 'role' is IGNORED on POST
  /api/auth/register; self-registered users always get the
  least-privilege 'CS Rep' role. Unauthenticated callers can no longer
  mint Admin/Jerome, Admin/Wahab, or Director accounts.

Tests:
- conftest.py sets test SECRET_KEY/CORS_ORIGINS before app import.
- New tests/test_p0_hardening.py (8 tests): role-escalation blocked for
  Admin/Jerome and Admin/Wahab, duplicate-email 409, and subprocess
  boot-validation for placeholder/short/missing secret + wildcard CORS.
- Full suite: 44 passed.

Redeploy note (per research): seed_units/seed_categories are insert-only,
so the Aug-26 redeploy does NOT orphan historical tickets referencing
units 103E/103W/105E/105W or the legacy 34-category tree. Pending
Wahab: are 103E/103W/105E/105W real apartments dropped from the Excel
regeneration? Optional follow-up: floor-number backfill for already-
seeded units (mapping corrected floors; existing rows keep old values).

Checks per HARDENING.md acceptance:
- [x] starting without a real key fails loudly (subprocess-verified)
- [x] compose carries no literal secret; secrets come from .env
- [x] CORS_ORIGINS explicit allow-list, '*' rejected
- [x] unauthenticated register cannot mint Admin/* or Director
2026-09-02 23:59:21 +00:00
mumuni-bot 76d9d12b78 Merge pull request 'feat: Phase 1 session timeout — access token 30 -> 60 min' (#9) from feat/phase1-session-timeout into main 2026-08-26 23:52:41 +00:00
Mumuni (Hermes) 3ad81e2c39 feat: increase access token lifetime 30 -> 60 min (Phase 1 session timeout) 2026-08-26 23:52:13 +00:00
mumuni-bot aef9d90097 Merge pull request 'feat: Phase 1 — apartment mapping (134 units) + 12 categories' (#8) from feat/phase1-apartment-mapping-categories-timeout into main 2026-08-26 23:51:40 +00:00
Mumuni (Hermes) c9b722041c fix: include Penthouse rows (PH1E-/PH1W-/PH2E-/PH2W-) in apartment mapping — parser had skipped them 2026-08-26 23:44:30 +00:00
Mumuni (Hermes) cf1d9413bb fix: COPY apartment_mapping.json into image (was missing -> built-in fallback used) 2026-08-26 21:29:55 +00:00
Mumuni (Hermes) f256f1a6d2 feat: Phase 1 — regenerate 134-unit mapping from Excel + add 12 categories 2026-08-26 15:27:59 +00:00
abiba-bot 78068c23d9 Merge pull request 'docs: Production hardening & review checklist (demo -> prod)' (#6) from docs/prod-hardening-checklist into main 2026-08-15 13:36:09 +00:00
abiba-bot 51e00a40cd Merge pull request 'feat: backdated reported date for old active tickets' (#7) from fm/denya-backdate-report-date into main 2026-08-03 09:59:06 +00:00
abiba-bot 1f15ca5457 docs: production hardening & review checklist for demo->prod transition
Grounded in hands-on review of the live scottdenya deployment and main.
P0 security blockers, P1 operational hardening, WhatsApp wiring runbook,
no-mistakes review items, and production Definition-of-Done.

Prepared by Mumuni (Syslog Falcon) 2026-08-03.
2026-08-03 08:05:26 +00:00
12 changed files with 1221 additions and 697 deletions
+15
View File
@@ -0,0 +1,15 @@
# Denya OneCare — runtime environment template (HARDENING.md P0.1/P1.1)
# Copy to .env and fill in real values. NEVER commit .env.
# Generate the secret with: openssl rand -hex 32
# ── Required ─────────────────────────────────────────────
SECRET_KEY=
DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
# Explicit origin allow-list — "*" is rejected at startup (P0.2)
CORS_ORIGINS=http://localhost:8000
# ── Optional (WhatsApp; needed before wiring Meta) ───────
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_VERIFY_TOKEN=
META_GRAPH_BASE=https://graph.facebook.com/v18.0
+1
View File
@@ -12,6 +12,7 @@ COPY pyproject.toml .
COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
COPY apartment_mapping.json .
# Install Python dependencies
RUN pip install --no-cache-dir .
+214
View File
@@ -0,0 +1,214 @@
# Denya OneCare — Production Hardening & Review Checklist
> **Audience:** Abiba (and any agent working the Denya OneCare repo)
> **Status:** Demo → Production hardening
> **Context:** WhatsApp integration lands within a week (once Denya provides credentials).
> We are moving past "demo" toward the final product. This doc is the concrete,
> ordered punch-list to get there. Each item is grounded in the current codebase
> (verified against `main` and the live deployment on `scottdenya`).
> **How to use:** work top-down. P0 items are hard blockers for any real data.
> When a P0/P1 item is done, mark it `[x]` and PR it with a `no-mistakes(review)` pass.
---
## 0. Current state (verified 2026-08-03)
- **Working & verified:** auth (JWT 30m/7d, bcrypt, RBAC via `require_roles`), ticket
CRUD with 16-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
legacy-schema self-heal. **32 pytest tests pass.**
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
image built 2026-08-02, `restart: unless-stopped`.
- **Known demo-only posture (must change):** `SECRET_KEY=change-me-in-production`,
`CORS_ORIGINS=*`, open `/api/auth/register`, SQLite backend, WhatsApp webhook
code is present but **no real credentials wired**.
---
## 1. P0 — Security blockers (do these FIRST, before any real data)
### P0.1 Hardcode-safe secrets; never ship the default key
- **Files:** `docker-compose.yml`, `app/core/config.py`
- Replace the hardcoded `SECRET_KEY=change-me-in-production` default with a
fail-closed default: if `SECRET_KEY` is unset/empty or still the well-known
placeholder string, refuse to boot (raise in `Settings` validation or lifespan).
- `docker-compose.yml` must NOT carry a literal secret. Reference an `.env`
(git-ignored) or a runtime secret source. Add `SECRET_KEY` + `WHATSAPP_*` to `.gitignore`.
- **Acceptance:** starting the app without a real key fails loudly; container env
contains a strong random key (≥32 bytes, e.g. `openssl rand -hex 32`).
### P0.2 Lock down CORS
- **Files:** `app/main.py`, `docker-compose.yml`
- `CORS_ORIGINS=*` + `allow_credentials=True` is an invalid/unsafe combo
(browsers reject `*` with credentials anyway). Replace with an explicit
origin allow-list of the real web origins (e.g. `https://denya.sysloggh.net`,
your NetBird/nomad domain + localhost for dev).
- If credentials are used, origins MUST be explicit — never `*`.
- **Acceptance:** `settings.CORS_ORIGINS` is a comma-separated explicit list; the
middleware builds an allow-list, not `["*"]`.
### P0.3 Gate user registration
- **File:** `app/routers/auth.py` (`POST /api/auth/register`)
- Today anyone on the network can self-register. Decide the model:
- **Recommended:** require an admin-issued invitation token, or restrict
registration to a seed/allowed list, or remove the open route and create
users only via seed/admin.
- If a public self-service resident/tenant signup is genuinely required
(Phase 2 QR/self-service), it must be a SEPARATE endpoint with a **role
default of the least-privilege role** and rate-limiting — never able to mint
admin/FM roles.
- **Acceptance:** a raw, unauthenticated register call can no longer mint an
`Admin/*` or `Director` account.
### P0.4 Reconsider SQLite for the final product
- **Files:** `docker-compose.yml`, `app/core/database.py`, `app/core/config.py`, PRD §16
- PRD Phase 1 calls for PostgreSQL. SQLite is fine for POC but is a write-lock
bottleneck and a data-integrity risk under concurrent FM/CS/WhatsApp writes.
- **Recommended:** switch `DATABASE_URL` to Postgres via async driver
(`postgresql+asyncpg://`). SQLAlchemy 2.0 + SQLAlchemy models are portable —
the migration is mostly: new driver dependency, `DATABASE_URL`, and re-running
Alembic against Postgres. Keep SQLite as the default for local dev/tests only.
- **Acceptance:** `pytest` green against Postgres (tests param via conftest),
Alembic applies cleanly on a fresh Postgres DB.
### P0.5 WhatsApp webhook auth + hardening (finish wiring, then lock it)
- **File:** `app/routers/whatsapp.py`
- The handler exists but no credentials are set. When wiring this week:
- Verify the `hub.verify_token` check is constant-time (compare with
`secrets.compare_digest`). **The GET verification path currently returns
`{"error": ...}` with HTTP 200** — flip to `403` on token mismatch.
- Validate **inbound messages only from Meta** — the webhook MUST authenticate
Meta's request signature (X-Hub-Signature-256 HMAC over the raw body with your
app secret) before processing, otherwise anyone who discovers the endpoint can
forge tickets. This is the single most important WhatsApp hardening item.
- Add per-sender rate limiting / dedupe on `wa_message_id` (webhook retries can
double-create tickets). Create an idempotency guard keyed on `wa_message_id`.
- Never log the raw access token; redact in `send_whatsapp_reply` error paths.
- **Acceptance:** a forged POST without the Meta signature is rejected; duplicate
`wa_message_id` does not create a second ticket; verify-token mismatch returns 403.
---
## 2. P1 — Operational hardening (before/just after go-live)
### P1.1 Secrets handling & git hygiene
- Ensure `SECRET_KEY`, `WHATSAPP_*`, and any DB credentials are **not** in the repo
or in the committed `docker-compose.yml`. `.env` is git-ignored.
- On this fleet: align with Syslog's key-off-disk doctrine — inject secrets at
runtime (Infisical) rather than baking into image or compose if feasible.
- Rotate the seed demo users' `denya123` password before production. `seed_users`
is idempotent but the default password is in `app/services/seed.py` — forced-rotate
on first prod login or at seed time.
### P1.2 Reverse proxy + TLS
- Do not expose the raw uvicorn :8000 behind `CORS_ORIGINS=*` on the WAN.
Terminate TLS at a reverse proxy (Caddy/Traefik/nginx) with a proper domain
(e.g. `denya.sysloggh.net`).
- Configure gunicorn/workers + `--proxy-headers` (or keep uvicorn but behind TLS).
- **Acceptance:** `https://denya.sysloggh.net` serves the app with a valid cert;
`:8000` is not directly reachable from the internet.
### P1.3 DB backups & persistence
- Postgres change (P0.4) enables sane backups. Wire nightly `pg_dump` (or PBS /
Syslog backup cron) of the persistent volume. The compose already mounts
`app-data` volume — make sure it's on backed-up storage.
- Add an Alembic upgrade step to the deploy runbook (never rely only on
`Base.metadata.create_all` + self-heal for schema changes in prod).
### P1.4 Logging & observability
- Add structured request logging; route to a location you can actually check
(stdout + a file/volume). Correlate with `ticket_number`.
- Add a minimal `/health` readiness that checks DB connectivity (currently it
returns OK without touching the DB).
### P1.5 Photo upload hardening
- **File:** `app/routers/tickets.py`
- Uploads already validate MIME + extension and use UUID filenames — good.
- Add: max file-size limit (e.g. 10 MB) and content sniffing (validate magic
bytes, not just `content_type` which is client-supplied).
- Ensure uploaded files are never executable and are served with
`X-Content-Type-Options: nosniff`.
### P1.6 API hardening & rate limiting
- Add rate limiting on `POST /api/auth/login` (brute-force) — per-IP/IP+account.
- Consider rate limits on ticket creation (spam / mass-creation).
- Normalize/validate `page_size` (already capped `le=200`) and pagination
tie-breaker (`id DESC` present — good).
---
## 3. WhatsApp integration (this week) — concrete wiring runbook
Assumes Denya provides: **phone number ID, access token, verify token, app secret.**
1. **Add env vars** (`WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`,
`WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, `META_GRAPH_BASE`) to `.env`
(git-ignored) and inject at runtime. Never commit.
2. **Webhook handshake:** in Meta dashboard point the webhook URL at
`<domain>/api/whatsapp/webhook`. The GET verify path currently echoes
`hub.challenge` when the verify token matches — confirm this works, then apply
P0.5 (403 on mismatch, HMAC signature validation).
3. **Verify incoming signature** (P0.5) — use `X-Hub-Signature-256` = HMAC-SHA256
of the raw body with your app secret, compared with `compare_digest`.
4. **Reply flow:** confirm `send_whatsapp_reply` posts correctly to
`graph.facebook.com/v18.0/<PHONE_NUMBER_ID>/messages`. The reply template
currently builds a JS string manually — prefer sending the nested object as a
proper JSON body rather than a hand-built string (`{\"body\":\"...\"}`) to avoid
escaping bugs. Test with the Meta "send a test message" tool.
5. **Idempotency:** guard ticket creation on `wa_message_id` (P0.5) to prevent
double-creation on retries.
6. **Standalone test:** use the `mock-log` endpoint to confirm webhook → ticket →
auto-reply path end-to-end in the demo env before pointing Meta's production
webhook at it.
---
## 4. Review recommendations (for the `no-mistakes(review)` pass and final QA)
- **RBAC coverage:** audit every route for the correct dependency. Currently:
- `POST /api/tickets`, `PATCH`, `POST /{id}/status`, `POST /{id}/photos` → any
authenticated user. Confirm role intent (should a CS Rep push a ticket to
"On-Field Verification"? or only FM/Tech?).
- `GET /api/tickets`, `GET /{id}`, `/transitions`, `/sla`, `/photos` are
**unauthenticated**. For a facilities tool this may be intentional (resident
view), but confirm you're comfortable with public reads of ticket details
(which include reporter/phone). If not, add auth.
- **Phone/tenant data exposure:** ticket detail returns `phone`. Decide who can
see phone numbers and enforce at the API, not just the UI.
- **Test coverage gaps to add:**
- Auth: expired token, malformed token, RBAC denial per role
- WhatsApp: signature validation (valid/invalid/forged), verify-token mismatch,
duplicate `wa_message_id` idempotency
- Pagination boundary: page > last page returns empty items, tie-breaker stable
- Photo upload: bad MIME spoofing, oversize file, `is_before` flag
- SLA: breach boundary exactly at deadline (not just past it)
- **Schema/migration hygiene:** the `ensure_legacy_schema` self-heal in
`app/main.py` exists because of create_all DBs. Once you move to Alembic-only
(P1.3), this becomes dead weight — plan a deprecation.
- **Concurrency:** ticket-number generation reads `max()` then `+1` — fine at
current scale, but under concurrent Postgres writes this can race. If tickets
ever originate from WhatsApp + web + dashboard simultaneously at volume, move to
a sequenced/unique constraint approach.
---
## 5. Definition of Done (production-ready)
- [ ] No default `SECRET_KEY`; app fails closed without a real key
- [ ] CORS is an explicit origin allow-list
- [ ] Self-registration cannot mint privileged roles (or is admin-gated/removed)
- [ ] Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
- [ ] TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
- [ ] WhatsApp webhook: Meta signature validated, verify-token mismatch → 403,
idempotent on `wa_message_id`, real credentials injected at runtime
- [ ] Login/ticket rate limiting in place
- [ ] Photo uploads size-limited and content-sniffed
- [ ] RBAC audited per-route; phone data access controlled
- [ ] Expanded test suite (auth, WhatsApp, SLA boundary, uploads) — all green
- [ ] Secrets out of repo; demo password rotated
- [ ] Structured logs + DB-aware health check
---
*Prepared by Mumuni (Syslog Falcon) — 2026-08-03, from a hands-on review of the
denya-onecare repo and the live scottdenya deployment.*
+795 -687
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db"
# ── Auth ─────────────────────────────────────────────────────────
SECRET_KEY: str = "change-me-in-production-use-a-real-secret"
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # Phase 1: raised 30 -> 60 for fewer re-logins
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# ── CORS ─────────────────────────────────────────────────────────
@@ -42,3 +42,21 @@ class Settings(BaseSettings):
settings = Settings()
# ── Fail-closed secret validation (HARDENING.md P0.1) ─────────────────
# Refuse to boot without a real SECRET_KEY. Devs must create a local .env
# (see .env.example); production injects it via docker-compose env_file.
_KNOWN_PLACEHOLDER_SECRETS = {
"",
"change-me-in-production",
"change-me-in-production-use-a-real-secret",
"changeme",
"secret",
}
if settings.SECRET_KEY in _KNOWN_PLACEHOLDER_SECRETS or len(settings.SECRET_KEY) < 32:
raise RuntimeError(
"SECRET_KEY is missing, a known placeholder, or shorter than 32 chars. "
"Generate one with: openssl rand -hex 32 — and set it in .env "
"(dev) or the runtime environment (prod). Refusing to start."
)
+9 -2
View File
@@ -83,10 +83,17 @@ app = FastAPI(
lifespan=lifespan,
)
# ── CORS ─────────────────────────────────────────────────────────────
# ── CORS (HARDENING.md P0.2 — explicit origin allow-list, never "*") ──
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
if "*" in _origins or not _origins:
raise RuntimeError(
"CORS_ORIGINS must be an explicit comma-separated origin allow-list "
"(e.g. 'https://denya.sysloggh.net,http://localhost:8000'). "
"'*' with allow_credentials=True is invalid and unsafe. Refusing to start."
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"],
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+4 -1
View File
@@ -10,7 +10,10 @@ class RegisterRequest(BaseModel):
password: str
full_name: str
phone: str | None = None
role: str = "CS Rep"
# HARDENING.md P0.3: role is NOT client-controllable. Self-registration
# always creates the least-privilege role; privileged roles are assigned
# by an admin directly in the DB (or a future admin-gated endpoint).
role: str = "CS Rep" # kept for backward compat; ignored by the service
class LoginRequest(BaseModel):
+11 -2
View File
@@ -16,9 +16,18 @@ from app.core.security import (
from app.models.user import User
from app.schemas.auth import RegisterRequest
# HARDENING.md P0.3 — least-privilege default for self-registered users.
_SELF_REGISTER_ROLE = "CS Rep"
async def register(db: AsyncSession, body: RegisterRequest) -> User:
"""Create a new user. Raises 409 if email already exists."""
"""Create a new user. Raises 409 if email already exists.
HARDENING.md P0.3: unauthenticated self-registration must never mint a
privileged role. The client-supplied ``role`` field is IGNORED — new
self-registered users always land on the least-privilege role.
Admins assign elevated roles directly (DB seed / admin endpoint).
"""
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
@@ -28,7 +37,7 @@ async def register(db: AsyncSession, body: RegisterRequest) -> User:
password_hash=hash_password(body.password),
full_name=body.full_name,
phone=body.phone,
role=body.role,
role=_SELF_REGISTER_ROLE,
)
db.add(user)
await db.flush()
+14
View File
@@ -152,6 +152,20 @@ SEED_CATEGORIES_DATA: list[dict] = [
{"type": "cs", "name": "Missing Item", "subs": ["Guest left items behind", "Item search request"]},
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
# ── Phase 1 additions (maintenance) ──────────────────────────
{"type": "maintenance", "name": "Kitchen Sink", "subs": ["Clogging"]},
{"type": "maintenance", "name": "Painting", "subs": []},
{"type": "maintenance", "name": "Gym", "subs": []},
{"type": "maintenance", "name": "Swimming Pool", "subs": []},
{"type": "maintenance", "name": "Shower Cord", "subs": []},
{"type": "maintenance", "name": "Sliding Doors", "subs": []},
{"type": "maintenance", "name": "Sliding Windows", "subs": []},
{"type": "maintenance", "name": "Low Water Pressure", "subs": []},
{"type": "maintenance", "name": "Damages", "subs": []},
# ── Phase 1 additions (customer service) ─────────────────────
{"type": "cs", "name": "Parking Issues", "subs": []},
{"type": "cs", "name": "Noise Complaints", "subs": []},
{"type": "cs", "name": "Waste Management", "subs": []},
# ── Emergency ────────────────────────────────────────────────
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
+2 -3
View File
@@ -4,10 +4,9 @@ services:
container_name: denya-onecare
ports:
- "8000:8000"
env_file:
- .env # git-ignored; see .env.example for required keys
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
- SECRET_KEY=change-me-in-production
- CORS_ORIGINS=*
- DEBUG=false
volumes:
- app-data:/app/data
+4
View File
@@ -12,6 +12,10 @@ import tempfile
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
# HARDENING.md P0.1/P0.2: the app now fails closed without a real SECRET_KEY
# and an explicit CORS allow-list — tests must satisfy both.
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production-0123456789abcdef")
os.environ.setdefault("CORS_ORIGINS", "http://test")
import pytest_asyncio # noqa: E402 (DATABASE_URL must be set before app imports)
from httpx import ASGITransport, AsyncClient # noqa: E402
+132
View File
@@ -0,0 +1,132 @@
"""P0 hardening regression tests (HARDENING.md P0.1 / P0.2 / P0.3).
Covers:
- P0.3: self-registration CANNOT mint a privileged role (role field ignored)
- P0.3: duplicate email still 409s
- 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 ASGITransport, AsyncClient
REPO_ROOT = Path(__file__).resolve().parent.parent
pytestmark = pytest.mark.asyncio
# ── P0.3: registration role-escalation ────────────────────────────────
async def test_register_cannot_mint_admin_role(client: AsyncClient):
"""A raw unauthenticated register call must NOT be able to mint Admin/*."""
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker",
"role": "Admin/Jerome",
},
)
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["role"] == "CS Rep", (
f"self-registration minted privileged role: {created['role']}"
)
async def test_register_role_wahab_also_blocked(client: AsyncClient):
resp = await client.post(
"/api/auth/register",
json={
"email": "attacker2@example.com",
"password": "Sup3rSecret!",
"full_name": "Attacker Two",
"role": "Admin/Wahab",
},
)
assert resp.status_code == 201
assert resp.json()["role"] == "CS Rep"
async def test_register_duplicate_email_conflict(client: AsyncClient):
payload = {
"email": "dupe@example.com",
"password": "Sup3rSecret!",
"full_name": "Dupe",
}
r1 = await client.post("/api/auth/register", json=payload)
assert r1.status_code == 201
r2 = await client.post("/api/auth/register", json=payload)
assert r2.status_code == 409
# ── P0.1 / P0.2: fail-closed boot validation ──────────────────────────
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