diff --git a/backend/api/auth.py b/backend/api/auth.py index 730b352..14236cb 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -26,6 +26,7 @@ from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, EmailStr from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from config import settings @@ -148,11 +149,18 @@ async def register( limit_bytes=104857600, # 100 MB default (STORE-01) used_bytes=0, ) - session.add(new_user) - await session.flush() # persist User before Quota FK - session.add(quota) - await session.commit() - await session.refresh(new_user) + try: + session.add(new_user) + await session.flush() # persist User before Quota FK + session.add(quota) + await session.commit() + await session.refresh(new_user) + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email or handle already in use", + ) return { "id": str(new_user.id), diff --git a/backend/load_tests/locustfile.py b/backend/load_tests/locustfile.py index 57f67ee..011876b 100644 --- a/backend/load_tests/locustfile.py +++ b/backend/load_tests/locustfile.py @@ -1,14 +1,20 @@ """Locust load test for DocuVault — D-04, D-05, D-06. -Strategy: self-bootstrapping (Option A) — on_start registers the load-test -user (409 ignored if already exists), then logs in to get an access token. +Strategy: pre-authenticated users (Option B) — a test_start listener creates +and authenticates all 50 virtual users before spawning begins. This avoids +the IP-based auth rate limiter (10 req/min) hitting during the ramp-up phase, +while still exercising per-user isolated document endpoints under real load. Run (headless, matches Phase 6 SLA gate): - locust --headless --users 50 --spawn-rate 10 --run-time 5m \\ + locust --headless --users 50 --spawn-rate 10 --run-time 12m \\ --host http://localhost:8000 \\ --csv backend/load_tests/results \\ -f backend/load_tests/locustfile.py + NOTE: --run-time must be 12m+ because Locust counts from process start. + Setup takes ~6 min (6 batches × 65 s to respect 10 req/min auth rate limit). + On re-runs users already exist (409 on register) — still need login pacing. + Prerequisites: pip install -r backend/requirements-dev.txt docker compose up # backend, postgres, minio must be running @@ -17,23 +23,84 @@ Exit codes (enforced by check_sla listener): 0 — all SLA thresholds met 1 — p95 > 200 ms, OR p99 > 500 ms, OR fail_ratio > 1% -Cleanup the load-test user: - docker compose exec postgres psql -U docuvault -c \\ - "DELETE FROM users WHERE email='loadtest@example.com';" +Cleanup load-test users: + docker compose exec postgres psql -U postgres -d docuvault -c \\ + "DELETE FROM users WHERE email LIKE 'loadtest%@example.com';" """ import os +import threading +import time from io import BytesIO +import requests as _req from locust import HttpUser, between, events, task -TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com") -TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#") -TEST_HANDLE = "loadtestuser" +_BASE_URL = os.environ.get("LOAD_TEST_HOST", "http://localhost:8000") +_TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#") +_NUM_USERS = int(os.environ.get("LOAD_TEST_USERS", "50")) + +# Populated by the test_start listener; each slot is an access_token string. +_TOKEN_POOL: list[str] = [] +_TOKEN_LOCK = threading.Lock() +_TOKEN_IDX = 0 -# Minimal synthetic PDF — valid enough for the upload parser _FAKE_PDF = b"%PDF-1.4\n1 0 obj<>endobj\nxref\n0 2\ntrailer<>\n%%EOF" +@events.test_start.add_listener +def pre_authenticate(environment, **kwargs) -> None: + """Create and authenticate all virtual users before spawning begins. + + Rate limit on /login is 10 req/min per IP (fixed-window). We use a + batch of 9 logins then a 65-second pause so each batch fits safely inside + one window before the counter resets. Setup runs before the test timer, + so this does not count against the 5-minute SLA window. + + 50 users ÷ 9 per batch = 6 batches × 65 s ≈ 6 minutes setup. + """ + print(f"[setup] Pre-creating {_NUM_USERS} load-test accounts …") + session = _req.Session() + batch_size = 9 # /register and /login each have 10 req/min; 9 per window is safe + + for batch_start in range(0, _NUM_USERS, batch_size): + batch_end = min(batch_start + batch_size, _NUM_USERS) + batch_num = batch_start // batch_size + 1 + total_batches = (_NUM_USERS + batch_size - 1) // batch_size + + for i in range(batch_start, batch_end): + email = f"loadtest{i}@example.com" + handle = f"loadtest{i}" + + # Register — 409 means already exists, both are fine + session.post( + f"{_BASE_URL}/api/auth/register", + json={"handle": handle, "email": email, "password": _TEST_PASSWORD}, + timeout=10, + ) + + # Login + r = session.post( + f"{_BASE_URL}/api/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + timeout=10, + ) + if r.status_code == 200: + _TOKEN_POOL.append(r.json().get("access_token", "")) + else: + print(f"[setup] WARNING: login failed for loadtest{i} (status={r.status_code})") + _TOKEN_POOL.append("") # placeholder keeps slot indices aligned + + if batch_end < _NUM_USERS: + print( + f"[setup] Batch {batch_num}/{total_batches} done ({batch_end}/{_NUM_USERS}). " + f"Waiting 65 s for rate-limit window reset …" + ) + time.sleep(65) + + ready = sum(1 for t in _TOKEN_POOL if t) + print(f"[setup] {ready}/{_NUM_USERS} accounts authenticated and ready.") + + class DocuVaultUser(HttpUser): """Simulated DocuVault end-user exercising all authenticated endpoints. @@ -48,18 +115,14 @@ class DocuVaultUser(HttpUser): access_token: str = "" def on_start(self) -> None: - self.client.post( - "/api/auth/register", - json={"handle": TEST_HANDLE, "email": TEST_EMAIL, "password": TEST_PASSWORD}, - ) - resp = self.client.post( - "/api/auth/login", - json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, - name="POST /api/auth/login", - ) - if resp.status_code == 200: - self.access_token = resp.json().get("access_token", "") + global _TOKEN_IDX + with _TOKEN_LOCK: + idx = _TOKEN_IDX + _TOKEN_IDX += 1 + if idx < len(_TOKEN_POOL): + self.access_token = _TOKEN_POOL[idx] else: + print(f"[worker] No token available for slot {idx} — stopping runner") self.environment.runner.quit() def _auth_headers(self) -> dict: @@ -88,8 +151,6 @@ class DocuVaultUser(HttpUser): @task(2) def upload_document(self) -> None: - # Direct single-step upload to /api/documents/upload (confirmed against documents.py: - # accepts UploadFile `file` + Form `target_backend` — simpler than the presigned flow). self.client.post( "/api/documents/upload", headers=self._auth_headers(), @@ -115,7 +176,12 @@ def check_sla(environment, **kwargs) -> None: p99 = stats.get_response_time_percentile(0.99) fail_ratio = stats.fail_ratio - if fail_ratio > 0.01: + total_reqs = stats.num_requests + if total_reqs < 500: + # Setup consumed the entire run-time window; re-run with --run-time 12m + print(f"SLA FAIL: only {total_reqs} requests — setup consumed run-time. Use --run-time 12m") + environment.process_exit_code = 1 + elif fail_ratio > 0.01: print(f"SLA FAIL: fail_ratio={fail_ratio:.2%} > 1%") environment.process_exit_code = 1 elif p95 > 200: @@ -125,4 +191,4 @@ def check_sla(environment, **kwargs) -> None: print(f"SLA FAIL: p99={p99:.0f}ms > 500ms") environment.process_exit_code = 1 else: - print(f"SLA PASS: p95={p95:.0f}ms p99={p99:.0f}ms fail_ratio={fail_ratio:.2%}") + print(f"SLA PASS: p95={p95:.0f}ms p99={p99:.0f}ms fail_ratio={fail_ratio:.2%} reqs={total_reqs}")