Files
samiandClaude Sonnet 5 3325efc1c8 pkg: add tools/testserver — the hostile HTTP server
Zero-dependency Python 3.11+ single file. 16 failure modes selectable by
URL path and combinable with '+': no-range, lies-about-accept-ranges,
etag-changes, flaky-reset (TCP RST mid-body, clean on the 3rd try),
slow-loris, redirect-chain, 401-basic, 401-digest, 403-without-referer,
416-always, content-length-mismatch, expiring-signed-url, throttled,
chunked-no-length, utf8/legacy content-disposition. Deterministic
synthetic bodies (byte i = f(seed, path, i)) with a /sha256/ reference
route so any range is independently verifiable. /__control {"reset":true}
clears flaky-mode counters between cases.

selftest.py exercises every mode (43 checks) and is registered as the
`testserver_selftest` CTest. README documents the full surface — CORE's
M1 DoD is written against it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
2026-09-09 19:22:36 +04:00

64 lines
3.9 KiB
Markdown

# 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 /<mode>[+<mode>...]/file/<size>
GET /<mode>/sha256/<size> -> {"sha256": "<hex>", "size": <n>} reference digest
GET /<mode>/sign/<size>?ttl=<sec> -> {"url": "...", "exp": <unixtime>} (signed-URL mode)
```
`<size>` 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 */<size>`. 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/<size>?ttl=<sec>`. 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`.