Local .env or shell export — defaults to a fixed value matching AUTH-01 strength rules
truths
artifacts
key_links
locust is installable from requirements-dev.txt and is NOT installed by the production Dockerfile
locustfile.py exposes a DocuVaultUser HttpUser class with login → list → upload tasks weighted realistically
Running `locust --headless --users 50 --spawn-rate 10 --run-time 5m` against a running stack exits 0 only when p95 < 200ms AND p99 < 500ms AND fail_ratio ≤ 1%
Locust on_start is self-bootstrapping — calls /api/auth/register first (409 conflict ignored), then /api/auth/login
The upload task path matches the actual documents.py shape (presigned upload-url → PUT → confirm OR direct /upload — confirmed against documents.py before commit)
path
provides
contains
backend/requirements-dev.txt
locust pin (separate from production requirements.txt)
locust>=2.34.0
path
provides
contains
min_lines
backend/load_tests/locustfile.py
Full Locust user class + SLA gating quitting listener
class DocuVaultUser
80
path
provides
backend/load_tests/README.md
Run instructions, prerequisites, env var documentation, expected exit codes
documents.py upload endpoint shape (presigned vs direct)
verified by reading backend/api/documents.py first
api/documents/(upload|upload-url)
from
to
via
pattern
backend/load_tests/locustfile.py check_sla()
environment.process_exit_code
@events.quitting.add_listener
events.quitting.add_listener
Implement the Locust load test (D-04, D-05, D-06) that satisfies the SLA targets in Phase 6 success criterion 1: 50 concurrent users for a 5-minute soak with p50 < 100ms, p95 < 200ms, p99 < 500ms, and zero endpoint failures. Promote the locustfile.py skeleton from 06-01 to a full implementation. Resolve RESEARCH.md Open Question 1 by reading documents.py and using the correct upload flow shape.
Purpose: Make Phase 6 SC-01 measurable and gate-able. Provide a single command (locust --headless ...) whose exit code tells the on-call engineer whether the latest deployment meets SLA.
Output: requirements-dev.txt with locust pinned, a complete locustfile.py with SLA-gating quitting listener, and a load_tests/README.md so a fresh operator can run the test without reading PLAN.md.
POST /api/auth/register — body {handle, email, password}, returns 201 on success or 409 if email exists.
POST /api/auth/login — body {email, password, totp_code?, backup_code?}, returns 200 with {access_token: str, ...} on success.
POST /api/auth/refresh — relies on the httpOnly refresh_token cookie which the Locust HttpUser session maintains automatically via the requests Session it wraps.
From backend/api/documents.py (verified at planning time):
GET /api/documents/ — list endpoint, returns array.
POST /api/documents/upload-url — body {filename, ...}, returns {upload_url, document_id}. Two-step presigned flow.
POST /api/documents/{id}/confirm — finalises after MinIO PUT, atomic quota UPDATE.
POST /api/documents/upload — alternative direct multipart flow used for cloud backends (also accepts target_backend=minio). Use the DIRECT /upload endpoint for the load test because it is a single HTTP call from the client's perspective and avoids needing a MinIO presigned PUT from the Locust process.
From RESEARCH.md Pattern 7 + Open Question 1 resolution:
Locust HttpUser maintains an HttpSession (cookies preserved across calls within one virtual user) — refresh_token cookie persists between login and refresh calls.
Task 1: Confirm load-test credential strategy
- .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pitfall 4 — load test user not pre-created)
- .planning/phases/06-performance-production-hardening/06-CONTEXT.md (D-04 prerequisites)
Two strategies for the load-test user. The planner needs the operator to pick one before implementation.
Option A — self-bootstrapping: locustfile on_start calls POST /api/auth/register (silently swallows 409 if user already exists), then POST /api/auth/login. Pros: zero out-of-band setup; works against a clean DB. Cons: writes a real user into the production-shape DB; password defaults to a fixed string unless LOAD_TEST_PASSWORD env var is set.
Option B — pre-seeded user via Alembic seed: planner writes a one-off migration or makefile target to create the load-test user, locustfile only logs in. Pros: load test does not pollute the user table during runs. Cons: extra setup step; operator must run the seed before every fresh DB.
1. Decide: Option A (self-bootstrap, default) or Option B (pre-seeded).
2. If Option A: confirm the load-test handle/email default — the planner uses `loadtestuser` / `loadtest@example.com` / `Loadtest123!@#` unless overridden by LOAD_TEST_EMAIL / LOAD_TEST_PASSWORD env vars. Confirm this password meets AUTH-01 strength rules: ≥12 chars, upper, lower, digit, special — "Loadtest123!@#" is 14 chars and has all classes.
3. If Option B: provide the seeding command you want the planner to document in README.md.
- User selects "A" or "B"; planner proceeds with that strategy in Task 3.
- If A: README.md documents that the loadtest user persists in the DB and can be cleaned up with `DELETE FROM users WHERE email='loadtest@example.com'`.
- If B: README.md documents the seeding command before-each-run.
Type "A" for self-bootstrap (default) or "B" for pre-seeded with the seeding command.
Task 2: Add locust to requirements-dev.txt
backend/requirements-dev.txt
- backend/requirements.txt (current pins — do NOT modify this file)
- .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Standard Stack section — "Locust is a dev/test dependency only")
Create backend/requirements-dev.txt (new file). Top of file: comment block stating "Development and load-test dependencies. NOT installed by the production Dockerfile. Install separately on the host or in a dedicated venv via: `pip install -r backend/requirements-dev.txt`." First include line: `-r requirements.txt` so the dev environment also installs everything from production. Then a `# Load testing (Phase 6 — D-04)` comment followed by `locust>=2.34.0`. Do NOT modify backend/requirements.txt — locust must NOT enter the production image (per RESEARCH.md and the gates checklist).
test -f backend/requirements-dev.txt && grep -c '^locust' backend/requirements-dev.txt && ! grep -q '^locust' backend/requirements.txt
- File backend/requirements-dev.txt exists: `test -f backend/requirements-dev.txt` returns 0.
- File contains `-r requirements.txt`: `grep -c '^-r requirements.txt' backend/requirements-dev.txt` returns 1.
- File contains `locust>=2.34.0`: `grep -c '^locust>=2.34.0' backend/requirements-dev.txt` returns 1.
- backend/requirements.txt does NOT contain locust: `grep -c '^locust' backend/requirements.txt` returns 0.
- File parses as valid pip requirements: `pip install --dry-run -r backend/requirements-dev.txt 2>&1 | grep -cE 'ERROR|error'` returns 0.
locust installable from requirements-dev.txt, production requirements.txt unchanged.
Task 3: Implement locustfile.py + README.md
backend/load_tests/locustfile.py, backend/load_tests/README.md
- backend/api/documents.py (lines around `upload_document` and `confirm_upload` — confirm the request body shape for /upload and /upload-url + /{id}/confirm before finalising the upload task)
- backend/api/auth.py (register/login request body shapes)
- backend/load_tests/locustfile.py (the skeleton from 06-01 — replace bodies)
- .planning/phases/06-performance-production-hardening/06-RESEARCH.md (Pattern 7, Pitfall 4, Open Question 1 + 2)
- .planning/phases/06-performance-production-hardening/06-PATTERNS.md (locustfile.py section)
Replace the body of backend/load_tests/locustfile.py with the full Pattern 7 implementation, parameterised by the credential strategy chosen in Task 1.
Top of file docstring: name, purpose, run command (`locust --headless --users 50 --spawn-rate 10 --run-time 5m --host http://localhost:8000 --csv backend/load_tests/results -f backend/load_tests/locustfile.py`), prerequisites, SLA exit-code semantics.
Imports: `import os`, `from io import BytesIO`, `from locust import HttpUser, task, between, events`. NO imports from backend application code.
Constants: TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com"); TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#"); TEST_HANDLE = "loadtestuser".
Class DocuVaultUser(HttpUser): wait_time = between(0.5, 2.0); access_token: str = "".
Method on_start(self): if strategy A — POST /api/auth/register with {handle: TEST_HANDLE, email: TEST_EMAIL, password: TEST_PASSWORD} catching 409 silently; THEN POST /api/auth/login with {email, password}; on 200 set self.access_token from response JSON access_token; on any other status call self.environment.runner.quit().
Method _auth_headers(self): return {"Authorization": f"Bearer {self.access_token}"}.
Tasks (weighted per D-05 realistic session):
@task(5) list_documents(self): self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/").
@task(2) upload_document(self): use the DIRECT /api/documents/upload endpoint with form-data (target_backend="minio", file fake PDF bytes b"%PDF-1.4 ..." in a BytesIO), headers from _auth_headers, name="POST /api/documents/upload". Confirm before committing that documents.py /upload accepts the target_backend form field.
@task(3) get_document(self): list first to pick an id; if list is non-empty pick the first doc and GET /api/documents/{id}, name="GET /api/documents/{id}"; if empty, skip silently (do not record as failure).
@task(1) refresh_token(self): POST /api/auth/refresh, name="POST /api/auth/refresh" — relies on HttpSession cookie jar to carry refresh_token.
SLA listener (module-level): `@events.quitting.add_listener def check_sla(environment, **kwargs):` reads environment.runner.stats.total; if fail_ratio > 0.01 → environment.process_exit_code = 1; elif p95 > 200 → 1; elif p99 > 500 → 1.
Create backend/load_tests/README.md documenting: purpose, install command (`pip install -r backend/requirements-dev.txt`), run command (full headless example), env vars (LOAD_TEST_EMAIL, LOAD_TEST_PASSWORD with safe defaults), SLA semantics, exit-code mapping, cleanup tip for the loadtest user, and a note that load tests run OUTSIDE the production container.
cd backend && pip install -q locust && python -c "import ast; ast.parse(open('load_tests/locustfile.py').read())" && cd backend && locust -f load_tests/locustfile.py --headless --users 1 --spawn-rate 1 --run-time 5s --host http://localhost:8000 --only-summary 2>&1 | tail -5
- locustfile.py is syntactically valid: `python -c "import ast; ast.parse(open('backend/load_tests/locustfile.py').read())"` exits 0.
- `grep -c "class DocuVaultUser" backend/load_tests/locustfile.py` returns 1.
- `grep -c "raise NotImplementedError" backend/load_tests/locustfile.py` returns 0 (all skeleton stubs replaced).
- `grep -c "events.quitting.add_listener" backend/load_tests/locustfile.py` returns 1.
- `grep -cE "p95|get_response_time_percentile.0\\.95" backend/load_tests/locustfile.py` returns ≥ 1.
- `grep -cE "p99|get_response_time_percentile.0\\.99" backend/load_tests/locustfile.py` returns ≥ 1.
- `grep -cE "^from (backend|api|services|db|deps)" backend/load_tests/locustfile.py` returns 0 (no application imports).
- `grep -c "@task" backend/load_tests/locustfile.py` returns 4 (list, upload, get, refresh).
- locust accepts the file without parse errors: `locust -f backend/load_tests/locustfile.py --list-commands 2>&1 | grep -cE 'error|Error|invalid'` returns 0.
- README.md exists: `test -f backend/load_tests/README.md` returns 0.
- README.md mentions LOAD_TEST_EMAIL: `grep -c LOAD_TEST_EMAIL backend/load_tests/README.md` returns ≥ 1.
- The actual /api/documents/upload endpoint shape was confirmed against documents.py before commit (planner records this in SUMMARY).
locustfile.py runs without parse errors, defines four weighted tasks, SLA listener gates exit code on p95/p99/fail_ratio, README.md explains how to operate it.
<threat_model>
Trust Boundaries
Boundary
Description
Locust host process → backend API
Same as any HTTP client; uses real auth tokens, exercises real rate limits
LOAD_TEST_PASSWORD env var → process memory
Credential lives only in env vars and the HTTP session — never written to disk by locust
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-06-03-01
Information Disclosure
Load-test credentials hardcoded in version control
mitigate
TEST_EMAIL/TEST_PASSWORD read from env vars with non-sensitive defaults; README documents using a real password via LOAD_TEST_PASSWORD; .env already in .gitignore
T-06-03-02
Tampering
Locust added to production Docker image
mitigate
locust placed in requirements-dev.txt only; production Dockerfile installs only requirements.txt; grep gate verifies locust is absent from requirements.txt
T-06-03-03
Denial of Service
Load test against production by mistake
accept
Operator responsibility; --host flag is required for locust to run; README warns to use http://localhost:8000 or a dedicated staging URL
T-06-03-04
Tampering
locustfile import of application code couples test harness to deployment
mitigate
grep gate enforces zero imports from backend/api/services/db/deps in locustfile.py
T-06-03-SC
Tampering
locust install from PyPI
mitigate
Package legitimacy verified in 06-01 Task 1 (blocking human checkpoint); install gated behind that approval
</threat_model>
- backend/requirements.txt has no `^locust` line.
- backend/requirements-dev.txt has `^locust>=2.34.0`.
- locustfile.py parses, defines DocuVaultUser, has four @task decorators, has the SLA quitting listener, and zero application imports.
- README.md exists and documents the run command, env vars, SLA semantics.
- Operator-confirmed credential strategy (A or B) recorded in SUMMARY.
<success_criteria>
D-04 satisfied: Locust is the load tester, file lives at backend/load_tests/locustfile.py, runnable headless.
D-05 satisfied: Tasks cover login (on_start) → list → get → upload, weighted realistically; cloud endpoints excluded.
D-06 satisfied: SLA listener exits non-zero when p95 > 200ms OR p99 > 500ms OR fail_ratio > 1%.
locust kept out of production image (gates checklist item).
</success_criteria>
Create `.planning/phases/06-performance-production-hardening/06-03-SUMMARY.md` when done. Include: credential strategy chosen (A/B), the exact upload endpoint shape used (presigned three-step vs direct one-step) and why, full locustfile line count, sample dry-run output (5s run against a running backend).