- revoke_all_refresh_tokens: add skip_token_hash optional param (exclude
current session while revoking others)
- change_password, enable_totp, disable_totp: call revoke with skip hash
derived from refresh cookie; return sessions_revoked in response and
write to audit log metadata_
- 3 new tests: test_{change_password,enable_totp,disable_totp}_revokes_other_sessions
— all PASSED; 373 total passing, 0 regressions
- Frontend toasts: SettingsAccountTab + TotpEnrollment show
"Other sessions have been terminated." when sessions_revoked > 0
- Companion fixes: rate_limiting get_client_ip refactor, deps/auth.py
request.state.current_user, locustfile refresh-token task removal
- Version bump: 0.1.0 → 0.1.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
"""Locust load test for DocuVault — D-04, D-05, D-06.
|
||
|
||
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 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
|
||
|
||
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 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
|
||
|
||
_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
|
||
|
||
_FAKE_PDF = b"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\nxref\n0 2\ntrailer<</Size 2>>\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.
|
||
|
||
Task weights mirror a realistic read-heavy session per D-05:
|
||
5 — list documents (most common action)
|
||
3 — get single document
|
||
2 — upload a document
|
||
1 — refresh access token
|
||
"""
|
||
|
||
wait_time = between(0.5, 2.0)
|
||
access_token: str = ""
|
||
|
||
# NOTE: /api/auth/refresh requires an httpOnly cookie that Locust cannot
|
||
# obtain without a full browser session. Removed from the task mix to avoid
|
||
# guaranteed 401s that inflate the fail_ratio and mask real regressions.
|
||
|
||
def on_start(self) -> None:
|
||
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:
|
||
return {"Authorization": f"Bearer {self.access_token}"}
|
||
|
||
@task(6)
|
||
def list_documents(self) -> None:
|
||
self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/")
|
||
|
||
@task(3)
|
||
def get_document(self) -> None:
|
||
resp = self.client.get(
|
||
"/api/documents/",
|
||
headers=self._auth_headers(),
|
||
name="GET /api/documents/ (for get_document)",
|
||
)
|
||
if resp.status_code == 200:
|
||
docs = resp.json().get("items", [])
|
||
if docs:
|
||
doc_id = docs[0]["id"]
|
||
self.client.get(
|
||
f"/api/documents/{doc_id}",
|
||
headers=self._auth_headers(),
|
||
name="GET /api/documents/{id}",
|
||
)
|
||
|
||
@task(2)
|
||
def upload_document(self) -> None:
|
||
self.client.post(
|
||
"/api/documents/upload",
|
||
headers=self._auth_headers(),
|
||
files={"file": ("load_test.pdf", BytesIO(_FAKE_PDF), "application/pdf")},
|
||
data={"target_backend": "minio"},
|
||
name="POST /api/documents/upload",
|
||
)
|
||
|
||
|
||
|
||
@events.quitting.add_listener
|
||
def check_sla(environment, **kwargs) -> None:
|
||
"""Gate the Locust exit code on D-06 SLA thresholds."""
|
||
stats = environment.runner.stats.total
|
||
p95 = stats.get_response_time_percentile(0.95)
|
||
p99 = stats.get_response_time_percentile(0.99)
|
||
fail_ratio = stats.fail_ratio
|
||
|
||
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:
|
||
print(f"SLA FAIL: p95={p95:.0f}ms > 200ms")
|
||
environment.process_exit_code = 1
|
||
elif p99 > 500:
|
||
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%} reqs={total_reqs}")
|