diff --git a/CHANGELOG.md b/CHANGELOG.md index fd26814fb..c0c8659b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(load-tests) add JMeter load-testing scenarios and a LoadTest configuration - ✨(backend) make the upload ACL configurable to support GCS based storages - ✨(frontend) show the messages widget button on the homepage - ✨(frontend) open the messages widget from the help menu diff --git a/load-tests/.gitignore b/load-tests/.gitignore new file mode 100644 index 000000000..e947eefdc --- /dev/null +++ b/load-tests/.gitignore @@ -0,0 +1,4 @@ +results/ +user.properties +jmeter.log +.DS_Store diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 000000000..19997b9fa --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,196 @@ +# Load tests + +JMeter load-testing scenarios for the Drive backend. The goal is to measure +backend response times and error codes under load. WOPI editors and the IDP +are out of scope: authentication uses the `e2e/user-auth` endpoint (enabled +by the `LOAD_E2E_URLS` setting), like the e2e tests do. + +## Scenarios + +| File | Profile | Use it for | +|---|---|---| +| `drive-session.jmx` | Mixed ~50/50 read/write: every session creates a folder, uploads, deletes | Stressing the write path (uploads, deletes, storage) | +| `drive-session-read-heavy.jmx` | Read-dominant: every session browses; only `UPLOAD_PCT`% (default 15) also write | Realistic drive traffic — reads vastly outnumber writes | + +Pass the scenario file as the first argument of `run.sh` (default: +`drive-session.jmx`): + +```bash +./run.sh drive-session-read-heavy.jmx -JUSERS=100 -JRAMP_UP=60 -JDURATION=600 +``` + +## Prerequisites + +- Java 17+ and [Apache JMeter](https://jmeter.apache.org/) 5.6+ + (`brew install jmeter` on macOS) +- A target instance exposing the e2e auth endpoints (`LOAD_E2E_URLS`). The + local docker compose stack has them. For a production-like target, deploy + it with `DJANGO_CONFIGURATION=LoadTest`: this configuration is identical + to `Production` but enables the endpoints. `LOAD_E2E_URLS` is + intentionally not readable from the environment, and the backend refuses + to start if it is enabled on any production configuration — a real + production can never expose these endpoints. +- The object storage domain contained in the upload policy must be reachable + from the machine running JMeter (uploads go straight to S3, not through + the backend). For the local stack this is `http://localhost:9000` + (`AWS_S3_DOMAIN_REPLACE`). + +## Quick start + +```bash +cd load-tests +./run.sh # 5 users, 120s, against http://localhost:8071 +./run.sh -JUSERS=100 -JRAMP_UP=60 -JDURATION=600 +./run.sh -JBASE_URL=https://drive-load.example.com -JUSERS=500 -JRAMP_UP=300 -JDURATION=1800 +``` + +Each run writes into `results/`, prefixed with the scenario name: + +- `-.jtl` — raw per-request results (CSV) +- `--report/index.html` — HTML dashboard (percentiles, + error codes per transaction, throughput over time) + +To debug or edit the scenario, open it in the GUI (`jmeter -t +drive-session.jmx`) with 1 user and a "View Results Tree" listener. Never +run an actual load from the GUI. + +## Parameters + +All parameters are JMeter properties, passed as `-J=` or via a +local `user.properties` file (see `user.properties.example`). + +| Property | Default | Description | +|---|---|---| +| `BASE_URL` | `http://localhost:8071` | Backend base URL, no trailing slash | +| `USERS` | `5` | Number of concurrent virtual users (threads) | +| `RAMP_UP` | `5` | Seconds over which users are started | +| `DURATION` | `120` | Test duration in seconds | +| `LOOPS` | `-1` | Iterations per user (`-1` = until DURATION elapses) | +| `USER_OFFSET` | `0` | Added to the thread number to build the user email | +| `USER_EMAIL` | _(unset)_ | Log every thread in with this exact email instead of the generated `load-user-@test.test` ones. Debug/smoke only: all virtual users then share one account | +| `THINK_BASE_MS` | `2000` | Minimum think time between user actions (ms) | +| `THINK_RANGE_MS` | `3000` | Random extra think time (ms), so 2–5s by default | +| `FIXTURES_DIR` | `files` | Directory whose entries are randomly uploaded | +| `UPLOAD_PCT` | `15` | Read-heavy scenario only: percentage of sessions that also upload (the first session of each user always does, to seed data) | +| `UPLOAD_ACL` | `private` | Must match the backend `AWS_S3_UPLOAD_ACL` setting | +| `CONNECT_TIMEOUT_MS` | `10000` | TCP connect timeout | +| `RESPONSE_TIMEOUT_MS` | `60000` | Response timeout | + +## The mixed scenario (`drive-session.jmx`) + +Each virtual user is `load-user-@test.test` +(auto-created by the auth endpoint). A user logs in once — followed by the +app-shell bootstrap requests a browser fires on first page load (`00 app +bootstrap`: `GET /config/`, `GET /users/me/`, `GET /entitlements/`) — then +loops over a basic session, with think time between actions: + +1. `01 list root items` — `GET /items/` +2. `02 view recents` — `GET /items/recents/` (the `/explorer/items/recent` + page) +3. `03 view shared with me` — `GET /items/?is_creator_me=false` (the + `/explorer/items/shared-with-me` page) +4. `04 create folder` — `POST /items/` +5. `05 open folder` — `GET /items/{id}/children/` +6. `06 upload entry` — picks ONE random top-level entry of `files/` + (`FIXTURES_DIR`) and uploads it: + - a plain file: `POST children` (file item) + `PUT` to object storage + (presigned URL) + `POST upload-ended`; + - a folder (e.g. `mon_dossier`): mirrors the frontend folder upload — + the folder hierarchy is created first (`POST children`, parents before + children), then every file is uploaded into its created parent with + the same three-request sequence. Dotfiles (`.DS_Store`…) are skipped. + The traversal is computed by `scripts/plan_upload.groovy`; the requests + themselves are regular HTTP samplers so they appear individually in the + report. No think time inside the upload: for a real user, dropping a + folder is a single action. +7. `07 browse folder` — `GET /items/{id}/children/` +8. `08 move to trash` — `DELETE` the iteration folder (with its content) +9. `09 view trash` — `GET /items/trashbin/` +10. `10 empty trash` — `DELETE /items/{id}/hard-delete/` on the folder + +Every request asserts its exact expected status code, so any deviation shows +up as an error with the actual code in the report. The scenario cleans up +after itself (hard-delete), so the database does not grow across runs — +except folders orphaned by iterations interrupted when the test duration +elapses, and the `load-user-*` user rows themselves. + +Note: the upload policy expires 60 seconds after the file item is created +(`AWS_S3_UPLOAD_POLICY_EXPIRATION`), which is why there is deliberately no +think time inside the upload transaction. + +## The read-heavy scenario (`drive-session-read-heavy.jmx`) + +Same login/bootstrap and same building blocks, but the session models +realistic drive usage where reads vastly outnumber writes. With +`UPLOAD_PCT=15`, roughly 90% of transactions (and ~80% of HTTP requests, +uploads being heavy) are reads. + +Every session (iteration): + +1. `01 list root items`, `02 view recents`, `03 view shared with me` — as in + the mixed scenario. The root listing also picks a random folder to visit. +2. `04 open folder` / `05 open subfolder` — navigate into the randomly + picked folder, then into one of its subfolders (skipped when the account + has no folder yet). + +Then, only for the **first iteration of each user** (guaranteed) and for +`UPLOAD_PCT`% of the following ones: + +3. `06 create folder`, `07 upload entry`, `08 browse new folder` — the write + path of the mixed scenario. The first iteration is a **seed**: it always + uploads the fixture folder (`mon_dossier`, a 3-level tree) and is NOT + deleted, so later read iterations browse real data. +4. `09 move to trash`, `10 view trash`, `11 empty trash` — non-seed writes + clean up after themselves, as in the mixed scenario. + +A read-heavy run intentionally leaves data behind: the seed folders, plus +any folder orphaned by an iteration interrupted when `DURATION` elapsed. +The target instance is expected to be reset by ops (database + bucket) +between test campaigns — consecutive runs without a reset accumulate one +seed per user per run and skew comparisons. + +Implementation notes: + +- The write branch is an If Controller with condition + `first iteration OR __Random(1,100) <= UPLOAD_PCT`. +- Random folder picks use the JSON extractor's random-match mode + (`match_numbers=0`), excluding transient write folders + (`load--` with iteration > 0) so a folder is never browsed + while another thread of the same account is deleting it. +- The seed pick is implemented in `scripts/plan_upload.groovy`, activated by + the `seed_first_iteration` variable defined only by this scenario — + `drive-session.jmx` behavior is unchanged. + +## Distributed runs + +`${__threadNum}` is only unique within one JMeter instance. For multiple +injector machines, give each injector a distinct `USER_OFFSET` in its local +`user.properties` (copy `user.properties.example`) so user emails stay +globally unique. + +Rather than JMeter's RMI master/slave mode, prefer launching independent +injectors, each running `./run.sh` with the same `BASE_URL`/`DURATION` and +its own `USER_OFFSET`. Afterwards, merge the results and build a single +report: + +```bash +# concatenate JTLs (keep the header of the first file only) +awk 'FNR==1 && NR!=1 {next} {print}' injector-*/results/*.jtl > merged.jtl +jmeter -g merged.jtl -o merged-report +``` + +Injector sizing rule of thumb: 1000–2000 threads per well-tuned machine. +Raise the JVM heap (`JVM_ARGS="-Xms1g -Xmx4g"`) and the open-files limit +(`ulimit -n 65535`) before large runs. + +## Interpreting results + +- Read p90/p95/p99 per transaction in the dashboard, not averages. +- The `06 upload entry` transaction includes the object-storage leg (the + `PUT file to storage` samples); if it degrades while other transactions + stay flat, look at the storage, not the backend. +- `POST upload-ended` triggers async post-processing (mimetype detection, + previews, search indexing). Watch the Celery queue length during runs: a + healthy API with an exploding queue is still a capacity problem. +- Reset the target instance (database + bucket) between large campaigns so + accumulated data does not skew comparisons. diff --git a/load-tests/drive-session-read-heavy.jmx b/load-tests/drive-session-read-heavy.jmx new file mode 100644 index 000000000..dfd10c887 --- /dev/null +++ b/load-tests/drive-session-read-heavy.jmx @@ -0,0 +1,825 @@ + + + + + false + false + true + + + + + + + + + BASE_URL + ${__P(BASE_URL,http://localhost:8071)} + = + + + seed_first_iteration + true + = + + + + + + continue + + false + ${__P(LOOPS,-1)} + + ${__P(USERS,5)} + ${__P(RAMP_UP,5)} + true + ${__P(DURATION,120)} + 0 + true + + + + + false + false + + + + + + Content-Type + application/json + + + Accept + application/json + + + X-CSRFToken + ${csrftoken} + + + + + + + + true + + + + false + {"email": "${login_email}"} + = + + + + ${BASE_URL}/api/v1.0/e2e/user-auth/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_login_email.groovy + true + + + + + + true + csrftoken + csrftoken=([^;]+) + $1$ + NO_CSRF + 1 + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/config/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + + ${BASE_URL}/api/v1.0/users/me/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + + ${BASE_URL}/api/v1.0/entitlements/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/?page_size=50 + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + browse_folder_id + $.results[?(@.type == 'folder' && !(@.title =~ /^load-[0-9]+-[1-9][0-9]*/))].id + 0 + NONE + + + + groovy + scripts/reset_browse_vars.groovy + true + + + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/recents/?page_size=200 + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/?page=1&page_size=100&is_creator_me=false + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + ${__jexl3("${browse_folder_id}" != "NONE")} + false + true + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${browse_folder_id}/children/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + browse_subfolder_id + $.results[?(@.type == 'folder' && !(@.title =~ /^load-[0-9]+-[1-9][0-9]*/))].id + 0 + NONE + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + ${__jexl3("${browse_subfolder_id}" != "NONE")} + false + true + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${browse_subfolder_id}/children/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + ${__jexl3("${__jm__Users__idx}" == "0" || ${__P(UPLOAD_PCT,15)} >= ${__Random(1,100)})} + false + true + + + + false + true + + + + true + + + + false + {"title": "load-${__threadNum}-${__jm__Users__idx}", "type": "folder"} + = + + + + ${BASE_URL}/api/v1.0/items/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + folder_id + $.id + 1 + FOLDER_ID_NOT_FOUND + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + groovy + scripts/plan_upload.groovy + true + + + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + updir + current_updir + true + + + + true + + + + false + {"title": "${cdir_title}", "type": "folder"} + = + + + + ${BASE_URL}/api/v1.0/items/${cdir_parent}/children/ + POST + UTF-8 + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_subfolder.groovy + true + + + + + + cdir_id + $.id + 1 + SUBFOLDER_ID_NOT_FOUND + + + + groovy + scripts/store_subfolder_id.groovy + true + + + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + upfile + current_upfile + true + + + + true + + + + false + {"filename": "${cf_name}", "type": "file"} + = + + + + ${BASE_URL}/api/v1.0/items/${cf_parent}/children/ + POST + UTF-8 + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_file.groovy + true + + + + + + cfile_id;cpolicy_url + $.id;$.policy + 1;1 + FILE_ID_NOT_FOUND;POLICY_NOT_FOUND + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + + + + + + ${cf_abs} + + ${cf_mime} + + + + false + ${cpolicy_url} + PUT + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + + Content-Type + ${cf_mime} + + + X-amz-acl + ${__P(UPLOAD_ACL,private)} + + + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + true + + + + false + {} + = + + + + ${BASE_URL}/api/v1.0/items/${cfile_id}/upload-ended/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/children/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + ${__jexl3("${__jm__Users__idx}" != "0")} + false + true + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/ + DELETE + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 204 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/trashbin/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/hard-delete/ + DELETE + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 204 + + + Assertion.response_code + false + 8 + + + + + + + + + + diff --git a/load-tests/drive-session.jmx b/load-tests/drive-session.jmx new file mode 100644 index 000000000..7af1990f9 --- /dev/null +++ b/load-tests/drive-session.jmx @@ -0,0 +1,735 @@ + + + + + false + false + true + + + + + + + + + BASE_URL + ${__P(BASE_URL,http://localhost:8071)} + = + + + + + + continue + + false + ${__P(LOOPS,-1)} + + ${__P(USERS,5)} + ${__P(RAMP_UP,5)} + true + ${__P(DURATION,120)} + 0 + true + + + + + false + false + + + + + + Content-Type + application/json + + + Accept + application/json + + + X-CSRFToken + ${csrftoken} + + + + + + + + true + + + + false + {"email": "${login_email}"} + = + + + + ${BASE_URL}/api/v1.0/e2e/user-auth/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_login_email.groovy + true + + + + + + true + csrftoken + csrftoken=([^;]+) + $1$ + NO_CSRF + 1 + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/config/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + + ${BASE_URL}/api/v1.0/users/me/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + + ${BASE_URL}/api/v1.0/entitlements/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/?page_size=50 + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/recents/?page_size=200 + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/?page=1&page_size=100&is_creator_me=false + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + true + + + + false + {"title": "load-${__threadNum}-${__jm__Users__idx}", "type": "folder"} + = + + + + ${BASE_URL}/api/v1.0/items/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + folder_id + $.id + 1 + FOLDER_ID_NOT_FOUND + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/children/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + groovy + scripts/plan_upload.groovy + true + + + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + updir + current_updir + true + + + + true + + + + false + {"title": "${cdir_title}", "type": "folder"} + = + + + + ${BASE_URL}/api/v1.0/items/${cdir_parent}/children/ + POST + UTF-8 + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_subfolder.groovy + true + + + + + + cdir_id + $.id + 1 + SUBFOLDER_ID_NOT_FOUND + + + + groovy + scripts/store_subfolder_id.groovy + true + + + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + upfile + current_upfile + true + + + + true + + + + false + {"filename": "${cf_name}", "type": "file"} + = + + + + ${BASE_URL}/api/v1.0/items/${cf_parent}/children/ + POST + UTF-8 + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + groovy + scripts/prep_file.groovy + true + + + + + + cfile_id;cpolicy_url + $.id;$.policy + 1;1 + FILE_ID_NOT_FOUND;POLICY_NOT_FOUND + + + + + 201 + + + Assertion.response_code + false + 8 + + + + + + + + + + + ${cf_abs} + + ${cf_mime} + + + + false + ${cpolicy_url} + PUT + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + + Content-Type + ${cf_mime} + + + X-amz-acl + ${__P(UPLOAD_ACL,private)} + + + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + true + + + + false + {} + = + + + + ${BASE_URL}/api/v1.0/items/${cfile_id}/upload-ended/ + POST + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/children/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/ + DELETE + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 204 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/trashbin/ + GET + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 200 + + + Assertion.response_code + false + 8 + + + + + + false + true + + + + + + + ${BASE_URL}/api/v1.0/items/${folder_id}/hard-delete/ + DELETE + false + false + true + ${__P(CONNECT_TIMEOUT_MS,10000)} + ${__P(RESPONSE_TIMEOUT_MS,60000)} + + + + ${__P(THINK_BASE_MS,2000)} + ${__P(THINK_RANGE_MS,3000)} + + + + + 204 + + + Assertion.response_code + false + 8 + + + + + + + + diff --git a/load-tests/files/chantier.mp4 b/load-tests/files/chantier.mp4 new file mode 100644 index 000000000..00e2d21c3 Binary files /dev/null and b/load-tests/files/chantier.mp4 differ diff --git a/load-tests/files/conseil_municipal.pdf b/load-tests/files/conseil_municipal.pdf new file mode 100644 index 000000000..e6ba8fd73 Binary files /dev/null and b/load-tests/files/conseil_municipal.pdf differ diff --git a/load-tests/files/logo.png b/load-tests/files/logo.png new file mode 100644 index 000000000..c95c11b2d Binary files /dev/null and b/load-tests/files/logo.png differ diff --git a/load-tests/files/mon_dossier/1er Janvier 2024/Le Matin/file-identifier (1).svg b/load-tests/files/mon_dossier/1er Janvier 2024/Le Matin/file-identifier (1).svg new file mode 100644 index 000000000..59c967c77 --- /dev/null +++ b/load-tests/files/mon_dossier/1er Janvier 2024/Le Matin/file-identifier (1).svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/load-tests/files/mon_dossier/chantier.mp4 b/load-tests/files/mon_dossier/chantier.mp4 new file mode 100644 index 000000000..00e2d21c3 Binary files /dev/null and b/load-tests/files/mon_dossier/chantier.mp4 differ diff --git a/load-tests/files/mon_dossier/logo.png b/load-tests/files/mon_dossier/logo.png new file mode 100644 index 000000000..c95c11b2d Binary files /dev/null and b/load-tests/files/mon_dossier/logo.png differ diff --git a/load-tests/files/record_screen copie.mov b/load-tests/files/record_screen copie.mov new file mode 100644 index 000000000..bc7fe7555 Binary files /dev/null and b/load-tests/files/record_screen copie.mov differ diff --git a/load-tests/files/record_screen.mov b/load-tests/files/record_screen.mov new file mode 100644 index 000000000..bc7fe7555 Binary files /dev/null and b/load-tests/files/record_screen.mov differ diff --git a/load-tests/run.sh b/load-tests/run.sh new file mode 100755 index 000000000..5babb75fd --- /dev/null +++ b/load-tests/run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Run a Drive load-test scenario in CLI mode. +# +# Usage: +# ./run.sh [scenario.jmx] [jmeter options] +# +# The first argument may name a scenario file (default: drive-session.jmx). +# +# Examples: +# ./run.sh -JUSERS=100 -JRAMP_UP=60 -JDURATION=600 +# ./run.sh drive-session-read-heavy.jmx -JUSERS=100 -JRAMP_UP=60 -JDURATION=600 +# ./run.sh -JBASE_URL=https://drive-load.example.com -JUSERS=500 -JRAMP_UP=300 -JDURATION=1800 +# +# Every -J property is optional, see README.md for the full list and defaults. +set -euo pipefail +cd "$(dirname "$0")" + +SCENARIO="drive-session.jmx" +if [ $# -ge 1 ] && [[ "$1" == *.jmx ]]; then + SCENARIO="$1" + shift +fi +if [ ! -f "$SCENARIO" ]; then + echo "Scenario file not found: $SCENARIO" >&2 + exit 1 +fi + +mkdir -p results + +EXTRA_ARGS=() +# Per-injector properties (USER_OFFSET notably), see user.properties.example. +if [ -f user.properties ]; then + EXTRA_ARGS+=(-q user.properties) +fi + +TS=$(date +%Y%m%d-%H%M%S) +RUN="results/$(basename "$SCENARIO" .jmx)-$TS" + +jmeter -n -t "$SCENARIO" \ + -l "$RUN.jtl" \ + -j "$RUN.log" \ + -e -o "$RUN-report" \ + "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}" \ + "$@" + +echo +echo "Raw results: $RUN.jtl" +echo "JMeter log: $RUN.log" +echo "HTML dashboard: $RUN-report/index.html" diff --git a/load-tests/scripts/plan_upload.groovy b/load-tests/scripts/plan_upload.groovy new file mode 100644 index 000000000..b8fe211e8 --- /dev/null +++ b/load-tests/scripts/plan_upload.groovy @@ -0,0 +1,73 @@ +// Picks a random entry (file or folder) from the fixtures directory and +// flattens it into JMeter variables consumed by the two ForEach controllers +// of the "04 upload entry" transaction: +// updir_1..N - folder relative paths, parents always before children +// upfile_1..M - files as "relative dir" + SEP + "absolute path" + SEP + "name" +// This mirrors the frontend folder upload (useUpload.tsx): materialize the +// folder hierarchy first, then upload each file into its created parent. +// This sampler performs no HTTP request and is excluded from the results. +import java.util.concurrent.ThreadLocalRandom +import org.apache.jmeter.services.FileServer + +SampleResult.setIgnore() + +def fixturesPath = props.getProperty("FIXTURES_DIR", "files") +def fixtures = new File(fixturesPath) +if (!fixtures.isAbsolute()) { + fixtures = new File(FileServer.getFileServer().getBaseDir(), fixturesPath) +} +def entries = (fixtures.listFiles() ?: new File[0]) + .findAll { !it.name.startsWith(".") } + .sort { it.name } +if (entries.isEmpty()) { + throw new IllegalStateException("No fixture entries found in " + fixtures.absolutePath) +} + +// Clear the plan of the previous iteration. +["updir_", "upfile_"].each { prefix -> + def i = 1 + while (vars.get(prefix + i) != null) { + vars.remove(prefix + i) + i++ + } +} + +// Seed mode (opt-in, used by the read-heavy scenario): on the first +// iteration of each thread, pick a folder entry deterministically so every +// user starts the run with a navigable tree to read from. Scenarios that do +// not define the seed_first_iteration variable keep the random pick. +def entry +if ("true" == vars.get("seed_first_iteration") && vars.getIteration() == 1) { + entry = entries.find { it.isDirectory() } ?: entries[0] +} else { + entry = entries[ThreadLocalRandom.current().nextInt(entries.size())] +} +// Unit separator: cannot appear in file names, unlike "|" or ",". +def SEP = "\u001F" +def dirs = [] +def files = [] + +def collect +collect = { File node, String rel -> + if (node.isDirectory()) { + dirs << rel + (node.listFiles() ?: new File[0]) + .findAll { !it.name.startsWith(".") } + .sort { it.name } + .each { child -> collect(child, rel + "/" + child.name) } + } else { + def relDir = rel.substring(0, rel.lastIndexOf("/")) + files << [relDir, node.absolutePath, node.name].join(SEP) + } +} + +if (entry.isDirectory()) { + collect(entry, entry.name) +} else { + files << ["", entry.absolutePath, entry.name].join(SEP) +} + +dirs.eachWithIndex { d, i -> vars.put("updir_" + (i + 1), d) } +files.eachWithIndex { f, i -> vars.put("upfile_" + (i + 1), f) } +vars.put("upload_entry", entry.name) +log.info("upload plan: entry=${entry.name} dirs=${dirs.size()} files=${files.size()}") diff --git a/load-tests/scripts/prep_file.groovy b/load-tests/scripts/prep_file.groovy new file mode 100644 index 000000000..2e893b45a --- /dev/null +++ b/load-tests/scripts/prep_file.groovy @@ -0,0 +1,10 @@ +// Splits the current upfile_N entry ("relative dir" + SEP + "absolute path" +// + SEP + "name", see plan_upload.groovy) and resolves the parent item id +// and mimetype for the file about to be uploaded. +def parts = vars.get("current_upfile").split("\u001F", 3) +def relDir = parts[0] +vars.put("cf_parent", relDir.isEmpty() ? vars.get("folder_id") : vars.get("upfid_" + relDir)) +vars.put("cf_abs", parts[1]) +vars.put("cf_name", parts[2]) +def mime = java.net.URLConnection.guessContentTypeFromName(parts[2]) +vars.put("cf_mime", mime ?: "application/octet-stream") diff --git a/load-tests/scripts/prep_login_email.groovy b/load-tests/scripts/prep_login_email.groovy new file mode 100644 index 000000000..5a3c99a2a --- /dev/null +++ b/load-tests/scripts/prep_login_email.groovy @@ -0,0 +1,11 @@ +// Resolves the login email for this thread. If the USER_EMAIL property is +// set (-JUSER_EMAIL=someone@example.com), every thread logs in with it; +// otherwise each thread gets a unique generated address based on +// USER_OFFSET + thread number (ctx.getThreadNum() is 0-based, the generated +// names start at 1 to match the documented load-user-1..N scheme). +def email = props.getProperty("USER_EMAIL") +if (email == null || email.trim().isEmpty()) { + def offset = (props.getProperty("USER_OFFSET") ?: "0") as int + email = "load-user-" + (offset + ctx.getThreadNum() + 1) + "@test.test" +} +vars.put("login_email", email) diff --git a/load-tests/scripts/prep_subfolder.groovy b/load-tests/scripts/prep_subfolder.groovy new file mode 100644 index 000000000..2cc2bf92a --- /dev/null +++ b/load-tests/scripts/prep_subfolder.groovy @@ -0,0 +1,13 @@ +// Computes the parent item id and the title for the subfolder being created. +// current_updir holds a relative path like "mon_dossier/1er Janvier 2024"; +// its parent is either the iteration root folder (single segment) or an +// already-created subfolder whose id was stored under "upfid_". +def rel = vars.get("current_updir") +def slash = rel.lastIndexOf("/") +if (slash == -1) { + vars.put("cdir_parent", vars.get("folder_id")) + vars.put("cdir_title", rel) +} else { + vars.put("cdir_parent", vars.get("upfid_" + rel.substring(0, slash))) + vars.put("cdir_title", rel.substring(slash + 1)) +} diff --git a/load-tests/scripts/reset_browse_vars.groovy b/load-tests/scripts/reset_browse_vars.groovy new file mode 100644 index 000000000..8d494ea18 --- /dev/null +++ b/load-tests/scripts/reset_browse_vars.groovy @@ -0,0 +1,4 @@ +// Runs on "01 list root items". Resets the browse variables so a skipped +// "04 open folder" step cannot leave a stale subfolder id from a previous +// iteration (browse_folder_id itself is re-extracted on every iteration). +vars.put("browse_subfolder_id", "NONE") diff --git a/load-tests/scripts/store_subfolder_id.groovy b/load-tests/scripts/store_subfolder_id.groovy new file mode 100644 index 000000000..f6b0c97a3 --- /dev/null +++ b/load-tests/scripts/store_subfolder_id.groovy @@ -0,0 +1,3 @@ +// Stores the id of the subfolder just created so that its children (folders +// and files) can reference it as parent. +vars.put("upfid_" + vars.get("current_updir"), vars.get("cdir_id")) diff --git a/load-tests/user.properties.example b/load-tests/user.properties.example new file mode 100644 index 000000000..a3c45e1fe --- /dev/null +++ b/load-tests/user.properties.example @@ -0,0 +1,19 @@ +# Per-injector JMeter properties for distributed runs. +# +# Copy this file to `user.properties` on each injector machine and give each +# one a distinct USER_OFFSET so virtual users get globally unique emails +# (load-user-@test.test). +# +# Example for 3 injectors running 1000 users each: +# injector 1: USER_OFFSET=0 -> load-user-1..1000 +# injector 2: USER_OFFSET=1000 -> load-user-1001..2000 +# injector 3: USER_OFFSET=2000 -> load-user-2001..3000 + +USER_OFFSET=0 + +# Any other property from the README can also be pinned here instead of +# being passed with -J on the command line, e.g.: +# BASE_URL=https://drive-load.example.com +# USERS=1000 +# RAMP_UP=600 +# DURATION=1800 diff --git a/src/backend/core/tests/test_settings.py b/src/backend/core/tests/test_settings.py index 50b8a5cfc..81a9c3c63 100644 --- a/src/backend/core/tests/test_settings.py +++ b/src/backend/core/tests/test_settings.py @@ -3,8 +3,9 @@ """ import pytest +from configurations import values -from drive.settings import Base +from drive.settings import Base, Feature, LoadTest, PreProduction, Production, Staging def test_invalid_settings_oidc_email_configuration(): @@ -28,3 +29,43 @@ class TestSettings(Base): "Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and " "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " ) + + +def test_load_e2e_urls_is_not_environment_configurable(): + """ + LOAD_E2E_URLS exposes credential-less login endpoints: it must be a plain + literal so it can never be flipped through an environment variable. + """ + assert Base.LOAD_E2E_URLS is False + assert not isinstance(Base.LOAD_E2E_URLS, values.Value) + + +@pytest.mark.parametrize("configuration", [Production, Feature, Staging, PreProduction]) +def test_load_e2e_urls_disabled_on_production_configurations(configuration): + """No production-facing configuration may expose the e2e auth endpoints.""" + assert configuration.LOAD_E2E_URLS is False + + +def test_load_e2e_urls_refused_outside_allowlist(): + """ + Even if a configuration sets LOAD_E2E_URLS to True, the application must + refuse to start unless the configuration is explicitly allowlisted. + """ + + class EvilProduction(Production): + """Fake production settings trying to expose the e2e auth endpoints.""" + + LOAD_E2E_URLS = True + + with pytest.raises(ValueError) as excinfo: + EvilProduction().post_setup() + + assert str(excinfo.value) == ( + "LOAD_E2E_URLS must never be enabled on the EvilProduction configuration." + ) + + +def test_load_e2e_urls_allowed_on_load_test_configuration(): + """The dedicated LoadTest configuration exposes the e2e auth endpoints.""" + assert LoadTest.LOAD_E2E_URLS is True + LoadTest().post_setup() diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index c12d1c45f..fe81c27e9 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -68,6 +68,10 @@ class Base(Configuration): """ DEBUG = False + # Deliberately a plain literal, not a values.Value: this setting exposes + # unauthenticated login endpoints and must never be switchable from the + # environment. Enable it only by deploying an allowlisted configuration + # (see the guard in post_setup). LOAD_E2E_URLS = False USE_SWAGGER = False @@ -1572,6 +1576,19 @@ def post_setup(cls): "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " ) + # The e2e auth endpoints allow logging in as anyone without credentials. + # They may only be exposed by configurations explicitly allowlisted + # here; adding one must be a deliberate, reviewed act. + if cls.LOAD_E2E_URLS and cls.__name__ not in ( + "Development", + "Test", + "ContinuousIntegration", + "LoadTest", + ): + raise ValueError( + f"LOAD_E2E_URLS must never be enabled on the {cls.__name__} configuration." + ) + if cls.POSTHOG_KEY is not None: posthog.api_key = cls.POSTHOG_KEY posthog.host = cls.POSTHOG_HOST @@ -1768,3 +1785,16 @@ class PreProduction(Production): nota bene: it should inherit from the Production environment. """ + + +class LoadTest(Production): + """ + Load-testing environment settings + + Identical to Production except it exposes the e2e authentication + endpoints used by the load-test scenarios (see load-tests/ at the + repository root). Never deploy this configuration on an environment + holding real user data. + """ + + LOAD_E2E_URLS = True