# tools/testserver — the hostile HTTP server Every failure mode a real download hits, reproducible on demand. Standard-library Python only (>= 3.11); no `pip install`. Owned by lane PKG/QA. **CORE's M1 definition of done is written in terms of this server.** ```bash tools/testserver/testserver.py --port 8080 tools/testserver/testserver.py --port 0 # ephemeral; the chosen port is printed to stdout python3 tools/testserver/selftest.py # smoke-test every mode (also a CTest) ``` ## Request shape ``` GET /[+...]/file/ GET //sha256/ -> {"sha256": "", "size": } reference digest GET //sign/?ttl= -> {"url": "...", "exp": } (signed-URL mode) ``` `` is a byte count with an optional `K`/`M`/`G` suffix (binary: KiB/MiB/GiB) — `1048576`, `64K`, `512M`, `5G`. The body is **deterministic**: byte *i* is a function of `(--seed, path, i)`, so any range is independently verifiable and the whole file has a stable SHA-256 (fetch it from the `/sha256/` route). Different paths/modes have different content; a given path is byte-stable across requests except where a mode says otherwise (`etag-changes` still serves stable bytes; only its ETag moves). Combine modes with `+`: `/throttled+etag-changes/file/1G`. ## Modes | Mode | Behaviour | |---|---| | *(omitted)* / `plain` | Well-behaved: honours `Range`, stable `ETag`, correct `Content-Length`, `Accept-Ranges: bytes`. | | `no-range` | No `Accept-Ranges`; `Range` ignored; always `200` full body. | | `lies-about-accept-ranges` | Advertises `Accept-Ranges: bytes` but ignores `Range` and returns `200`. | | `etag-changes` | `ETag` differs on every response. A `Range` with a non-matching `If-Range` gets `200` full — i.e. "the file changed under you". | | `flaky-reset` | Sends ~half the requested bytes then aborts the connection with a TCP **RST**. The 1st and 2nd attempt for a given `path+range` fail this way; the 3rd succeeds cleanly. `POST /__control {"reset":true}` clears the counters. | | `slow-loris` | Status line, headers, and the first bytes are dribbled out one byte at a time for `--loris-seconds`, then the rest streams normally. `Connection: close`. | | `redirect-chain` | `302` `--redirect-depth` times (default 5) before the real resource. Query string is preserved across hops. | | `401-basic` | HTTP Basic; credentials `test` / `test`. | | `401-digest` | HTTP Digest, `qop=auth`; credentials `test` / `test`. | | `403-without-referer` | `403` unless `Referer` names this server's origin; otherwise serves normally. | | `416-always` | Any `Range` request → `416` with `Content-Range: bytes */`. A plain `GET` still returns `200` so the re-probe path is exercised. | | `content-length-mismatch` | `Content-Length` header is correct-looking but the server sends ~half and closes unclean. | | `expiring-signed-url` | Requires `?exp=&sig=`. Past `exp` → `403 {"error":"expired"}`; bad/missing sig → `403`. Get a fresh URL from `/…/sign/?ttl=`. Pair with `download.refreshUrl`. | | `throttled` | Body rate-limited to `--throttle-bps` (default 1 MiB/s). | | `chunked-no-length` | `Transfer-Encoding: chunked`, no `Content-Length` — size unknown until the stream ends. | | `utf8-content-disposition` | `Content-Disposition: attachment; filename="rates.pdf"; filename*=UTF-8''%E2%82%AC%20rates.pdf` (RFC 5987 → "€ rates.pdf"). | | `legacy-content-disposition` | `Content-Disposition` with an RFC 2047 MIME encoded-word filename — the classic mojibake source. | ## Control & health | Route | | |---|---| | `GET /__health` | `200 ok` | | `POST /__control` `{"reset": true}` | Clears per-path attempt counters (`flaky-reset`). Call it between test cases. | ## Flags `--host` (default `127.0.0.1`) · `--port` (`0` = ephemeral) · `--seed` (content seed, default `1`) · `--loris-seconds` (default `5`) · `--redirect-depth` (default `5`) · `--throttle-bps` (default `1048576`) · `--verbose`.