Files
curo1305andClaude Sonnet 4.6 c38c6b1c01 feat(07.1): session revocation on privilege change — CR-01/CR-02/CR-03
- 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>
2026-06-05 12:47:16 +02:00

192 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}")