+
+"""
+
+# DOM drift: results are present but the .post / .postTitle structure changed.
+SEARCH_HTML_LAYOUT_DRIFT = """
+
+
+ Drifted Audiobook - Author
+ English
+
+
+"""
+
+
+def _patch_detail(html: str):
+ return patch(
+ "shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page",
+ return_value=html,
+ )
+
+
+def test_info_hash_is_normalized_to_canonical_btih() -> None:
+ """Whitespace/newlines are stripped and the hash upper-cased to a valid
+ 40-char btih (regression for #386 / 'Fix ABB magnet parsing')."""
+ with _patch_detail(DETAIL_HTML_MESSY_HASH):
+ magnet = scraper.extract_magnet_link("https://audiobookbay.lu/abss/x/", "audiobookbay.lu")
+ assert magnet is not None, "messy-but-valid info hash should still yield a magnet"
+ btih = re.search(r"xt=urn:btih:([0-9A-Fa-f]+)", magnet)
+ assert btih is not None, magnet
+ assert btih.group(1) == "ABC123DEF456789012345678901234567890ABCD"
+ assert len(btih.group(1)) == 40
+ assert "tr=" in magnet # tracker carried through
+
+
+def test_magnet_fallback_when_info_hash_cell_is_junk() -> None:
+ """When the Info Hash cell is invalid, the scraper recovers the hash from an
+ in-page magnet link rather than failing."""
+ with _patch_detail(DETAIL_HTML_MAGNET_FALLBACK):
+ magnet = scraper.extract_magnet_link("https://audiobookbay.lu/abss/y/", "audiobookbay.lu")
+ assert magnet is not None
+ assert "btih:1111111111111111111111111111111111111111" in magnet
+
+
+def test_missing_info_hash_returns_none_not_crash() -> None:
+ """No hash anywhere -> None (clean failure), never an exception."""
+ with _patch_detail("
nothing here
"):
+ assert (
+ scraper.extract_magnet_link("https://audiobookbay.lu/abss/z/", "audiobookbay.lu")
+ is None
+ )
+
+
+def test_search_layout_drift_degrades_to_empty() -> None:
+ """A changed results DOM yields zero parsed results without raising — the
+ ABB analogue of the AA layout-drift guard."""
+ with (
+ patch(
+ "shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page",
+ return_value=(SEARCH_HTML_LAYOUT_DRIFT, "https://audiobookbay.lu/?s=test"),
+ ),
+ patch(
+ "shelfmark.release_sources.audiobookbay.scraper.config.get",
+ return_value=0.0,
+ ),
+ ):
+ results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
+ assert results == [], f"drifted DOM should parse to no results, got {results!r}"
diff --git a/tests/download/test_doh_resolver_mock.py b/tests/download/test_doh_resolver_mock.py
new file mode 100644
index 0000000..2ddce1c
--- /dev/null
+++ b/tests/download/test_doh_resolver_mock.py
@@ -0,0 +1,79 @@
+"""DoH resolver integration against the e2e platform's mock DoH responder.
+
+The config-cluster analysis flagged DNS/DoH as a recurring break surface (#1028,
+#108). A fully hermetic DoH-over-the-network profile isn't feasible in the HTTP
+docker platform (DoH provider URLs are HTTPS + IP-pinned), so we exercise the
+*real* ``DoHResolver`` client against the platform's mock ``doh`` role here, over
+plain HTTP on localhost. This runs in normal CI (not just the nightly docker
+matrix) and guards the DoH JSON-parsing path the app relies on.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import os
+import threading
+from pathlib import Path
+from wsgiref.simple_server import WSGIServer, make_server
+
+import pytest
+
+MOCK_PATH = Path(__file__).resolve().parents[1] / "e2e" / "platform" / "mocks" / "mock_services.py"
+
+
+def _load_mock_doh_app(doh_map: str):
+ """Import the platform mock_services module wired for the ``doh`` role.
+
+ The module wires its routes at import time from ``MOCK_ROLE``/``DOH_MAP``, so
+ those must be set before loading it.
+ """
+ os.environ["MOCK_ROLE"] = "doh"
+ os.environ["DOH_MAP"] = doh_map
+ spec = importlib.util.spec_from_file_location("mock_doh_services", MOCK_PATH)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module.app
+
+
+@pytest.fixture(scope="module")
+def doh_url():
+ if not MOCK_PATH.exists():
+ pytest.skip(f"platform mock not found at {MOCK_PATH}")
+ app = _load_mock_doh_app("aa.mock.test=172.30.0.10,cf.mock.test=172.30.0.11")
+ server: WSGIServer = make_server("127.0.0.1", 0, app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ # Google JSON DoH style uses the /resolve endpoint.
+ yield f"http://127.0.0.1:{server.server_port}/resolve"
+ finally:
+ server.shutdown()
+
+
+def _resolver(doh_url: str):
+ from shelfmark.download.network import DoHResolver
+
+ # hostname/ip args are the DoH server's own identity (used only for recursion
+ # avoidance); the localhost values here are irrelevant to the lookups under test.
+ return DoHResolver(doh_url, "127.0.0.1", "127.0.0.1")
+
+
+def test_doh_resolves_mapped_host(doh_url) -> None:
+ """The real DoH client parses the mock's JSON answer into an A record."""
+ assert _resolver(doh_url).resolve("aa.mock.test", "A") == ["172.30.0.10"]
+
+
+def test_doh_nxdomain_returns_empty_not_error(doh_url) -> None:
+ """An unmapped name yields an empty list (Status 3), not an exception —
+ the path that, when mishandled, surfaced as silent download failures."""
+ assert _resolver(doh_url).resolve("unmapped.invalid", "A") == []
+
+
+def test_doh_resolver_caches_within_ttl(doh_url) -> None:
+ """A second lookup is served from cache (the resolver's documented behaviour)."""
+ resolver = _resolver(doh_url)
+ first = resolver.resolve("cf.mock.test", "A")
+ assert first == ["172.30.0.11"]
+ assert ("cf.mock.test", "A") in resolver._cache
+ assert resolver.resolve("cf.mock.test", "A") == first
diff --git a/tests/download/test_webseed_torrent_generator.py b/tests/download/test_webseed_torrent_generator.py
new file mode 100644
index 0000000..df2b47a
--- /dev/null
+++ b/tests/download/test_webseed_torrent_generator.py
@@ -0,0 +1,87 @@
+"""Validate the e2e platform's webseed .torrent generator.
+
+The ``full`` e2e profile relies on a tracker-less webseed torrent so a real
+qBittorrent can complete a real download from the mock origin over HTTP. If the
+generator emits malformed bencode or mismatched piece hashes, qBittorrent would
+silently never complete — so we cross-check the generator against shelfmark's own
+``bencode_decode`` / ``extract_info_hash_from_torrent`` here, in normal CI.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+from shelfmark.download.clients.torrent_utils import (
+ bencode_decode,
+ extract_info_hash_from_torrent,
+)
+
+GEN_PATH = (
+ Path(__file__).resolve().parents[1] / "e2e" / "platform" / "mocks" / "make_webseed_torrent.py"
+)
+
+
+def _load_generator():
+ if not GEN_PATH.exists():
+ pytest.skip(f"generator not found at {GEN_PATH}")
+ spec = importlib.util.spec_from_file_location("make_webseed_torrent", GEN_PATH)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+PAYLOAD = b"E2E webseed payload \x00\x01\x02 " * 2000 # ~50 KiB -> multiple pieces
+NAME = "sample-book.epub"
+WEBSEED = "http://mock-aa/payload/sample-book.epub"
+
+
+def test_generated_torrent_decodes_with_shelfmark_bencode() -> None:
+ gen = _load_generator()
+ raw = gen.build_webseed_torrent(NAME, PAYLOAD, WEBSEED, piece_length=16384)
+
+ decoded, _ = bencode_decode(raw)
+ assert isinstance(decoded, dict)
+ info = decoded[b"info"]
+ assert info[b"name"] == NAME.encode()
+ assert info[b"length"] == len(PAYLOAD)
+ # url-list (webseed) must point at the file the mock serves.
+ assert decoded[b"url-list"] == [WEBSEED.encode()]
+ # No tracker — the whole point is HTTP-only completion.
+ assert b"announce" not in decoded
+
+
+def test_piece_hashes_match_payload_bytes() -> None:
+ gen = _load_generator()
+ piece_len = 16384
+ raw = gen.build_webseed_torrent(NAME, PAYLOAD, WEBSEED, piece_length=piece_len)
+ decoded, _ = bencode_decode(raw)
+ pieces = decoded[b"info"][b"pieces"]
+
+ expected = b"".join(
+ hashlib.sha1(PAYLOAD[i : i + piece_len]).digest() for i in range(0, len(PAYLOAD), piece_len)
+ )
+ assert pieces == expected, "piece hashes do not match payload — qbit would never complete"
+ assert len(pieces) % 20 == 0
+
+
+def test_info_hash_matches_shelfmark_extractor() -> None:
+ """Our infohash helper must agree with shelfmark's torrent parser."""
+ gen = _load_generator()
+ raw = gen.build_webseed_torrent(NAME, PAYLOAD, WEBSEED)
+
+ ours = gen.info_hash(raw)
+ theirs = extract_info_hash_from_torrent(raw)
+ assert theirs is not None
+ assert ours.lower() == theirs.lower(), (ours, theirs)
+
+
+def test_generator_is_deterministic() -> None:
+ gen = _load_generator()
+ a = gen.build_webseed_torrent(NAME, PAYLOAD, WEBSEED)
+ b = gen.build_webseed_torrent(NAME, PAYLOAD, WEBSEED)
+ assert a == b, "torrent generation must be byte-deterministic for stable infohash"
diff --git a/tests/e2e/platform/.gitignore b/tests/e2e/platform/.gitignore
new file mode 100644
index 0000000..b8252d9
--- /dev/null
+++ b/tests/e2e/platform/.gitignore
@@ -0,0 +1,7 @@
+# Runtime state created by run-e2e.sh: per-profile shelfmark logs, the app's
+# /config (settings, users.db, secrets), downloaded books, and staging dirs.
+.state/
+
+# Python bytecode from the mock services + suite.
+__pycache__/
+*.pyc
diff --git a/tests/e2e/platform/README.md b/tests/e2e/platform/README.md
new file mode 100644
index 0000000..058dee0
--- /dev/null
+++ b/tests/e2e/platform/README.md
@@ -0,0 +1,231 @@
+# Shelfmark E2E Docker Testing Platform
+
+A hermetic, container-based end-to-end platform that boots the real Shelfmark app
+against **controllable** dependencies — a fake Anna's Archive, a Cloudflare gate, a
+mock FlareSolverr bypasser, a mock Prowlarr + a real qBittorrent, custom DNS
+servers, HTTP/SOCKS5 proxies, and a Tor profile — and runs a cluster test suite
+under each **config profile**.
+
+It exists to make the recurring bug clusters from the issue/PR analysis impossible
+to reintroduce silently. The biggest one — Tor/Cloudflare/bypasser (37 issues /
+67 fix PRs) — had almost no automated coverage; this platform changes that.
+
+```
+ pytest suite (host :8084)
+ │
+ ▼
+ shelfmark (under test) ── egress depends on the active profile:
+ ├─ direct ──────────────► mock-aa (.10) fake Anna's Archive
+ ├─ Cloudflare gate ─────► mock-cf (.11) ─► mock-aa [full: real Chrome solves it]
+ ├─ FlareSolverr ────────► mock-cf (.11) ─► mock-flaresolverr (.12) [bypasser-external]
+ ├─ custom DNS ──────────► coredns (.20) / coredns-blocked (.22) [dns-manual/blocked]
+ ├─ HTTP / SOCKS proxy ──► tinyproxy (.30) / microsocks (.31) [proxy-http/socks]
+ ├─ Tor (transparent) ───► in-image tor.sh [tor]
+ └─ Prowlarr → client ───► mock-prowlarr (.40) ─► qBittorrent [full: real download]
+
+ (all on one e2e docker network, 172.30.0.0/24, static IPs for DNS determinism)
+```
+
+## Quick start
+
+```bash
+# one profile
+make e2e-platform # baseline
+make e2e-platform-profile PROFILE=bypasser-external
+make e2e-platform-profile PROFILE=client-deluge
+make e2e-platform-full # heavy: real Chrome + DoH + real qBittorrent
+
+# the whole matrix
+make e2e-platform-matrix
+
+# build the heavy image once, then reuse it (matrix does this automatically)
+make e2e-platform-build
+E2E_NO_BUILD=1 tests/e2e/platform/run-e2e.sh env/dns-doh.env
+
+# debug: leave the stack up after the run
+KEEP_UP=1 tests/e2e/platform/run-e2e.sh env/dns-blocked.env
+```
+
+Requirements: Docker + Compose v2, and `uv` (for the pytest runner). The runner
+builds the Shelfmark image from the repo `Dockerfile`, boots the profile's stack,
+waits for `/api/health`, runs the suite, and tears down. `run-matrix.sh` builds the
+image **once** and reuses it across profiles (`E2E_NO_BUILD=1`) so the slow
+xvfb/chromium layer isn't rebuilt per profile.
+
+## How profiles work
+
+Each profile is an env file in `env/`. It sets:
+- `COMPOSE_PROFILES` — which optional services start (compose `profiles:`).
+- `SM_*` — the app's config, injected as container env. Shelfmark treats
+ deployment ENV as authoritative (`config.get`: "Deployment-level ENV values
+ always win"), so a profile fully determines the app's DNS/proxy/bypasser/source
+ configuration with no runtime mutation.
+- `E2E_PROFILE` — handed to pytest so the suite selects applicable tests.
+
+Tests declare applicability with `@pytest.mark.profiles(...)`. **A test with no
+marker is a profile-agnostic invariant and runs under every profile** — that is
+how one cluster test ("source must be reachable") becomes the config matrix.
+
+## The matrix (cluster × profile)
+
+Status column: ✅ = run live on Docker and passing. Every profile below was run
+end-to-end (`docker compose up` + suite + teardown) and passes.
+
+| Profile | Egress / what it proves | Clusters | Regression targets | Status |
+|---|---|---|---|---|
+| `baseline` | Direct to fake AA; search/parse + #1028 clean-failure | 2,3,4 | #198 #293 #214 #1040 #1028 | ✅ 9 passed |
+| `bypasser-external` | External bypasser wired; CF-gated search fails cleanly | 1 | #284 #202 #410 #369 | ✅ 5 passed |
+| `bypasser-disabled` | CF-gated AA + bypasser OFF → no results (control) | 1 | #202 #410 | ✅ 4 passed |
+| `dns-manual` | AA only resolvable via custom DNS (coredns) | config: DNS | #108 | ✅ 4 passed |
+| `dns-blocked` | System DNS NXDOMAINs AA; custom DNS resolves it | config: DNS | **#1028** | ✅ 4 passed |
+| `dns-doh` | System DNS blocks AA; **DoH over real HTTPS** resolves it | config: DoH | **#1028** #108 | ✅ 3 passed |
+| `proxy-http` | All egress via tinyproxy, **proven by proxy logs** | config: proxy | **#956** | ✅ 6 passed |
+| `proxy-socks` | All egress via SOCKS5 (microsocks), traversal-checked | config: proxy | #956 | ✅ 5 passed |
+| `tor` | `USING_TOR=true` boots clean (restarts=0) | 1/6 Tor boot | #1021 #940 #801 | ✅ 5 passed |
+| `client-transmission` | Prowlarr → **real Transmission** webseed download → /books | 5 clients | #1022 #634 | ✅ 4 passed |
+| `client-deluge` | Prowlarr → **real Deluge** webseed download → /books | 5 clients | #530 | ✅ 4 passed |
+| `full` | **real Chrome solves Cloudflare** + DoH + **real qBittorrent** download → /books (Moby-Dick) | 1,4,5 + DoH | **#284 #1030** #386 #1040 #214 | ✅ 6 passed |
+| *(every profile)* | boots healthy under PUID/PGID, no perm errors | 6 entrypoint | #171 #447 #801 | ✅ |
+
+> **The bypasser is download-time, not search-time.** Running the stack revealed
+> that shelfmark fetches AA search/detail with `allow_bypasser_fallback=False`, so a
+> search behind Cloudflare returns 503 **regardless** of the bypasser; the bypasser
+> (internal Chrome or external FlareSolverr) only runs during a file *download*
+> (`use_bypasser=True`). The bypasser profiles therefore assert a *clean*
+> CF-gated-search failure, while the **`full` profile exercises the real end-to-end
+> CF solve**: AA search/detail are reachable, but the AA slow-download link points
+> at the gate, so downloading Moby-Dick forces the in-image headless Chromium to
+> detect the challenge, solve it (`_bypass_method_cdp_solve`), and fetch the file —
+> verified live (`Challenge detected: cloudflare` → `Bypass successful` → Moby-Dick
+> in `/books`).
+>
+> **`bypasser-external` must set `SM_USING_EXTERNAL_BYPASSER=true`** — shelfmark does
+> **not** derive it from `EXT_BYPASSER_URL`; without it the app silently uses the
+> in-image Chrome bypasser instead of FlareSolverr.
+
+Coverage of the 7 clusters from the analysis:
+
+1. **Bypasser/Tor/Cloudflare** → `bypasser-external`, `bypasser-disabled`, `tor`.
+2. **Search/metadata** → `baseline` (`test_cluster_search_aa.py`, hermetic via the
+ `direct_download` source so no external metadata provider is needed).
+3. **AA parsing/mirrors** → `baseline` parse guards incl. the **layout-drift
+ fail-loud** test (#878/#879/#880).
+4. **Permissions/file-move** → `baseline` (`test_cluster_download_permissions.py`).
+5. **Torrent/usenet clients** → a mock Prowlarr + webseed torrent drives **three
+ real torrent clients** end to end (`full`=qBittorrent, `client-transmission`,
+ `client-deluge`) — completion detection + file move into `/books`. One
+ client-agnostic test (`test_cluster_clients.py`) covers all three.
+6. **Docker/entrypoint/PUID-PGID** → profile-agnostic health + boot-log checks,
+ run under every profile.
+7. **Audiobook/ABB** → parse-contract guards in
+ `tests/audiobookbay/test_scraper_contract.py` (info-hash normalization #386,
+ magnet fallback, layout drift). These run in **normal CI**, not the docker
+ matrix, because ABB hardcodes `https://` for its fetches (see Roadmap).
+
+### Proxy traversal (not just reachability)
+Because the app and the mock AA share the e2e network, a regression that ignores
+the proxy config would still reach AA directly. `test_egress_actually_traverses_proxy`
+drives a search and then inspects the proxy container's logs, so the proxy
+profiles prove the egress *went through* the proxy — a real guard for #956.
+
+### DoH — two layers
+- **Offline** (`tests/download/test_doh_resolver_mock.py`, normal CI): the real
+ `DoHResolver` is driven against the mock `doh` role over localhost HTTP, covering
+ JSON-answer parsing, NXDOMAIN → empty, and caching.
+- **In-stack** (`dns-doh` profile): the mock `doh` role serves the DNS JSON API over
+ **real HTTPS** (self-signed). The system resolver (coredns-blocked) NXDOMAINs
+ `aa.mock.test`, so the host can *only* be resolved via DoH; compose `extra_hosts`
+ redirects the `cloudflare-dns.com` provider to the in-stack mock and
+ `CERTIFICATE_VALIDATION=disabled` accepts the self-signed cert. The search reaching
+ AA proves the app's DoH path resolved the name end to end — **no app code change**.
+
+### The `full` profile — real Chrome + real client (`make e2e-platform-full`)
+The "everything real" heavy profile (test book: **Moby-Dick**), run nightly / on
+demand (excluded from the PR matrix). It spins up, with **no** mock bypasser, and
+**passes live** (6 passed):
+
+- **Real Chrome solves Cloudflare, end to end (VERIFIED).** AA search/detail are
+ reachable (`mock-aa`), but the AA *slow-download* link points at the Cloudflare
+ gate (`mock-cf`), whose challenge page runs JS that issues `cf_clearance` and
+ reloads. Downloading Moby-Dick forces the in-image headless Chromium (seleniumbase
+ CDP, in the `shelfmark` image via `xvfb`+`chromium`) to load the gate, detect the
+ challenge (`Challenge detected: cloudflare`), solve it (`_bypass_method_cdp_solve`),
+ and fetch the cleared "Download now" page → the file lands in `/books`. That
+ outcome is *only* reachable if Chrome solved the gate — the literal "spin a Chrome
+ browser" path and the strongest guard for the #1 cluster. Two subtleties this
+ surfaced, now handled by the mock: the cleared page must exceed the bypasser's
+ `_LOADING_BODY_LENGTH_MAX` (50 chars of innerText) or it loops as "still loading",
+ and the AA detail page must satisfy the brittle `original_nodes[-6]` parse (#880).
+- **DoH** on at boot.
+- **Real qBittorrent download.** A mock Prowlarr (`/api/v1/system/status`,
+ `/api/v1/indexer`, torznab search) returns one release whose `.torrent` is a
+ **tracker-less BEP-19 webseed** pointing at `mock-aa`'s HTTP payload. A real
+ qBittorrent completes the download over HTTP (no tracker/peer/seeder), and
+ shelfmark's completion detection + file move lands the book in `/books`. The
+ webseed torrent is generated by `mocks/make_webseed_torrent.py` (cross-checked
+ against shelfmark's own bencode/infohash parser in
+ `tests/download/test_webseed_torrent_generator.py`), and the whole
+ prowlarr→qBittorrent path is configured declaratively via env (`env/full.env`).
+
+## Components
+
+| Path | Purpose |
+|---|---|
+| `mocks/mock_services.py` | One Flask app, five roles (`origin-aa`, `cloudflare`, `flaresolverr`, `prowlarr`, `doh`) selected by `MOCK_ROLE`. `origin-aa` also serves the webseed payload + `.torrent`. |
+| `mocks/make_webseed_torrent.py` | Stdlib bencode + BEP-19 webseed `.torrent` generator for the `full` real-client download. |
+| `qbittorrent/qBittorrent.conf` | Real qBittorrent config (auth bypassed for the e2e subnet) for the `full` profile. |
+| `env/full.env` | The heavy `full` profile: real Chrome bypasser + DoH + real qBittorrent. |
+| `mocks/fixtures/*.html` | AA search/detail HTML in the **exact** shape the parser expects, plus drift/empty/no-files variants. |
+| `docker-compose.e2e.yml` | The stack; optional services gated by compose profiles, static IPs for DNS determinism. |
+| `dns/Corefile*`, `dns/mock.test.db` | coredns zones — working + ISP-block (NXDOMAIN). |
+| `env/*.env` | The config profiles (matrix rows). |
+| `suite/` | The pytest harness + cluster tests. |
+| `run-e2e.sh` / `run-matrix.sh` | Boot one profile / loop the matrix. |
+| `build-images.sh` | Build the heavy image once (`make e2e-platform-build`); reused via `E2E_NO_BUILD=1`. |
+
+### Fault injection
+The mock AA reproduces historical bugs deterministically. Injection rides inside
+the search query as `E2EINJECT:` (the app builds the AA URL itself and only
+forwards the user query as `q=`). Names: `no_files`, `empty`, `layout_drift`,
+`500`. The harness embeds them via `PlatformClient.direct_search(..., inject=...)`.
+
+## Gating PRs (block merge on e2e failure)
+
+The `.github/workflows/e2e-platform.yml` workflow runs on every PR. On a PR that
+touches relevant code (`shelfmark/**`, `Dockerfile`, `entrypoint.sh`, `tor.sh`,
+`tests/e2e/platform/**`) it runs the fast PR subset **and** the heavy `full`
+profile (real Chrome solving Cloudflare + DoH + real qBittorrent), then a single
+**`e2e required`** job aggregates them: it fails if any e2e job failed, and passes
+(so it never hangs) when the e2e jobs are skipped on an unrelated PR.
+
+The workflow producing a failing check is **not enough on its own** — GitHub only
+*blocks merge* on checks listed in branch protection. A repo **admin** must, once:
+
+- **UI:** Settings → Branches → branch protection rule for `main` →
+ *Require status checks to pass before merging* → add **`e2e required`**.
+- **or `gh` (admin token):**
+ ```bash
+ gh api -X PUT repos/calibrain/shelfmark/branches/main/protection \
+ -H "Accept: application/vnd.github+json" --input - <<'JSON'
+ { "required_status_checks": { "strict": true, "contexts": ["e2e required"] },
+ "enforce_admins": true, "required_pull_request_reviews": null, "restrictions": null }
+ JSON
+ ```
+
+After that, any failure in the e2e platform tests (including the `full` profile)
+blocks the PR from merging. Requiring just the one `e2e required` context covers
+the whole dynamic matrix, so the list never needs updating as profiles change.
+
+## Known limitations / follow-ups
+
+- **rTorrent.** Not in the matrix: its rakshasa-libtorrent has **no GetRight/webseed
+ support**, so the hermetic webseed torrent (which qBittorrent/Transmission/Deluge
+ all complete) leaves rTorrent stuck at 0%. Supporting it needs a real tracker +
+ seeder (peer download) — a follow-up that the webseed design intentionally avoids.
+- **Usenet clients (SABnzbd/NZBGet).** Not yet covered — completing a usenet download
+ hermetically needs a mock NNTP server serving the yEnc-encoded payload plus an NZB,
+ which is a separate (larger) build than the torrent webseed path.
+- **Audiobook (cluster 7) in-stack.** ABB hardcodes `https://`, so it's covered
+ offline (`tests/audiobookbay/test_scraper_contract.py`); an in-stack
+ `audiobookbay` role needs the same self-signed-HTTPS plumbing the `dns-doh` profile
+ now uses for DoH.
diff --git a/tests/e2e/platform/build-images.sh b/tests/e2e/platform/build-images.sh
new file mode 100755
index 0000000..e47be64
--- /dev/null
+++ b/tests/e2e/platform/build-images.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Build every buildable image in the e2e stack once (the heavy `shelfmark` image
+# plus the mock-* role images), so run-matrix.sh / run-e2e.sh with E2E_NO_BUILD=1
+# can reuse them instead of rebuilding the xvfb/chromium layer per profile.
+set -euo pipefail
+
+PLATFORM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$PLATFORM_DIR"
+
+# Activate every profile that owns a buildable service so they all get built.
+# (Download clients, coredns, proxies are pre-built images — nothing to build.)
+export COMPOSE_PROFILES="bypasser-external,full,dns-doh"
+echo "==> building shelfmark + mock images (one cold build of the chromium layer)"
+docker compose -f docker-compose.e2e.yml build
+echo "==> done. Reuse with: E2E_NO_BUILD=1 ./run-e2e.sh env/.env"
diff --git a/tests/e2e/platform/dns/Corefile b/tests/e2e/platform/dns/Corefile
new file mode 100644
index 0000000..01fbdc6
--- /dev/null
+++ b/tests/e2e/platform/dns/Corefile
@@ -0,0 +1,13 @@
+# coredns: authoritative for mock.test, forwards everything else.
+mock.test:53 {
+ file /zones/mock.test.db
+ log
+ errors
+}
+
+. :53 {
+ forward . 1.1.1.1 8.8.8.8
+ cache 30
+ log
+ errors
+}
diff --git a/tests/e2e/platform/dns/Corefile.blocked b/tests/e2e/platform/dns/Corefile.blocked
new file mode 100644
index 0000000..6e79b17
--- /dev/null
+++ b/tests/e2e/platform/dns/Corefile.blocked
@@ -0,0 +1,18 @@
+# coredns: ISP-DNS-block simulation. Resolves nothing under mock.test
+# (returns NXDOMAIN), so an app that relies on *system* DNS for book sources
+# fails — while an app that uses its own custom DNS resolver still works.
+# Regression harness for #1028 (internal bypasser used system DNS in subprocess).
+mock.test:53 {
+ template IN ANY mock.test {
+ rcode NXDOMAIN
+ }
+ log
+ errors
+}
+
+. :53 {
+ # Everything else still resolves, so only the book-source host is "blocked".
+ forward . 1.1.1.1 8.8.8.8
+ log
+ errors
+}
diff --git a/tests/e2e/platform/dns/mock.test.db b/tests/e2e/platform/dns/mock.test.db
new file mode 100644
index 0000000..7a0c5c4
--- /dev/null
+++ b/tests/e2e/platform/dns/mock.test.db
@@ -0,0 +1,13 @@
+$ORIGIN mock.test.
+$TTL 60
+@ IN SOA ns.mock.test. admin.mock.test. (
+ 1 ; serial
+ 7200 ; refresh
+ 3600 ; retry
+ 1209600 ; expire
+ 3600 ) ; minimum
+@ IN NS ns.mock.test.
+ns IN A 172.30.0.20
+aa IN A 172.30.0.10
+cf IN A 172.30.0.11
+doh IN A 172.30.0.21
diff --git a/tests/e2e/platform/docker-compose.e2e.yml b/tests/e2e/platform/docker-compose.e2e.yml
new file mode 100644
index 0000000..ca036c2
--- /dev/null
+++ b/tests/e2e/platform/docker-compose.e2e.yml
@@ -0,0 +1,294 @@
+# Shelfmark e2e Docker testing platform
+# ---------------------------------------------------------------------------
+# A hermetic stack: the app under test plus *controllable* dependencies
+# (fake Anna's Archive, a Cloudflare gate, a mock FlareSolverr bypasser, a DoH
+# responder, a DNS server, HTTP + SOCKS5 proxies, and a Tor profile).
+#
+# Config profiles are env-driven: pick a profile with an env file under env/
+# (it sets COMPOSE_PROFILES + the SM_* config the app boots with). The runner
+# brings the stack up per profile, runs the matching cluster tests, tears down.
+#
+# ./run-e2e.sh env/baseline.env
+# ./run-e2e.sh env/bypasser-external.env
+# ./run-e2e.sh env/dns-blocked.env
+#
+# See README.md for the full cluster x profile matrix.
+
+x-mock-build: &mock-build
+ build:
+ context: ./mocks
+ dockerfile: Dockerfile
+
+networks:
+ e2e:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.30.0.0/24
+
+services:
+ # ----- App under test --------------------------------------------------- #
+ shelfmark:
+ build:
+ context: ../../..
+ dockerfile: Dockerfile
+ target: ${SM_BUILD_TARGET:-shelfmark}
+ container_name: e2e-shelfmark
+ cap_add:
+ - NET_ADMIN # required by tor.sh iptables when USING_TOR=true
+ - NET_RAW
+ environment:
+ TZ: UTC
+ DEBUG: "true"
+ ONBOARDING: "false" # skip wizard; ephemeral storage
+ DISABLE_LOCAL_AUTH: "${SM_DISABLE_LOCAL_AUTH:-true}"
+ PUID: "${SM_PUID:-1000}"
+ PGID: "${SM_PGID:-1000}"
+ # --- source / mirror config (cluster 2/3) ---
+ DIRECT_DOWNLOAD_ENABLED: "${SM_DIRECT_DOWNLOAD_ENABLED:-true}"
+ AA_ADDITIONAL_URLS: "${SM_AA_URL:-http://mock-aa}"
+ # --- bypasser config (cluster 1) ---
+ USE_CF_BYPASS: "${SM_USE_CF_BYPASS:-false}"
+ # Selects the external (FlareSolverr) bypasser; when false the in-image
+ # Chrome (internal) bypasser is used. NOT derived from EXT_BYPASSER_URL.
+ USING_EXTERNAL_BYPASSER: "${SM_USING_EXTERNAL_BYPASSER:-false}"
+ EXT_BYPASSER_URL: "${SM_EXT_BYPASSER_URL:-}"
+ # --- prowlarr indexer + torrent client (cluster 5, `full` profile) ---
+ PROWLARR_ENABLED: "${SM_PROWLARR_ENABLED:-false}"
+ PROWLARR_URL: "${SM_PROWLARR_URL:-}"
+ PROWLARR_API_KEY: "${SM_PROWLARR_API_KEY:-}"
+ PROWLARR_TORRENT_CLIENT: "${SM_PROWLARR_TORRENT_CLIENT:-}"
+ QBITTORRENT_URL: "${SM_QBITTORRENT_URL:-}"
+ QBITTORRENT_USERNAME: "${SM_QBITTORRENT_USERNAME:-}"
+ QBITTORRENT_PASSWORD: "${SM_QBITTORRENT_PASSWORD:-}"
+ QBITTORRENT_CATEGORY: "${SM_QBITTORRENT_CATEGORY:-}"
+ # transmission / deluge / rtorrent (client-* profiles)
+ TRANSMISSION_URL: "${SM_TRANSMISSION_URL:-}"
+ TRANSMISSION_USERNAME: "${SM_TRANSMISSION_USERNAME:-}"
+ TRANSMISSION_PASSWORD: "${SM_TRANSMISSION_PASSWORD:-}"
+ DELUGE_HOST: "${SM_DELUGE_HOST:-}"
+ DELUGE_PORT: "${SM_DELUGE_PORT:-}"
+ DELUGE_PASSWORD: "${SM_DELUGE_PASSWORD:-}"
+ RTORRENT_URL: "${SM_RTORRENT_URL:-}"
+ # --- DNS / DoH (config cluster) ---
+ CUSTOM_DNS: "${SM_CUSTOM_DNS:-}"
+ CUSTOM_DNS_MANUAL: "${SM_CUSTOM_DNS_MANUAL:-}"
+ USE_DOH: "${SM_USE_DOH:-false}"
+ # Disable TLS verification so the in-stack DoH-over-HTTPS mock (self-signed)
+ # is accepted in the dns-doh profile. Default keeps verification ON.
+ CERTIFICATE_VALIDATION: "${SM_CERTIFICATE_VALIDATION:-enabled}"
+ # --- proxy (config cluster) ---
+ PROXY_MODE: "${SM_PROXY_MODE:-none}"
+ HTTP_PROXY_URL: "${SM_HTTP_PROXY:-}"
+ HTTP_PROXY: "${SM_HTTP_PROXY:-}"
+ SOCKS5_PROXY: "${SM_SOCKS5_PROXY:-}"
+ NO_PROXY: "${SM_NO_PROXY:-}"
+ # --- tor (cluster 1/6) ---
+ USING_TOR: "${SM_USING_TOR:-false}"
+ ports:
+ - "8084:8084"
+ volumes:
+ - ./.state/config:/config
+ - ./.state/books:/books
+ - ./.state/downloads:/downloads
+ - ./.state/tmp:/tmp/shelfmark
+ networks:
+ - e2e
+ dns:
+ # When a DNS profile is active, point the container's system resolver at
+ # our controllable server; otherwise Docker's embedded DNS (127.0.0.11).
+ - ${SM_SYSTEM_DNS:-127.0.0.11}
+ extra_hosts:
+ # Redirect the DoH provider hostname to the in-stack mock-doh (dns-doh
+ # profile). Harmless elsewhere — only the dns-doh profile enables DoH against
+ # the cloudflare provider, and /etc/hosts is consulted before the resolver.
+ - "cloudflare-dns.com:172.30.0.21"
+ restart: "no"
+
+ # ----- Fake Anna's Archive origin (always on) --------------------------- #
+ mock-aa:
+ <<: *mock-build
+ container_name: e2e-mock-aa
+ environment:
+ MOCK_ROLE: origin-aa
+ # When set (the `full` profile sets it to the CF gate), AA slow-download
+ # links point through Cloudflare so a real download forces the internal
+ # Chrome bypasser to solve the challenge. Empty -> same-origin (no CF).
+ SLOW_DOWNLOAD_BASE: "${SM_SLOW_DOWNLOAD_BASE:-}"
+ AA_FILE_BASE: "http://mock-aa"
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.10
+ aliases:
+ - aa.mock.test
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost/healthz')"]
+ interval: 3s
+ timeout: 3s
+ retries: 10
+
+ # ----- Cloudflare gate (profiles: bypasser-external, full) -------------- #
+ # `full` puts AA's slow-download behind this gate so the real Chrome bypasser
+ # must solve it; `bypasser-external` puts AA search behind it for the
+ # FlareSolverr negative/positive controls.
+ mock-cf:
+ <<: *mock-build
+ container_name: e2e-mock-cf
+ profiles: ["bypasser-external", "full"]
+ environment:
+ MOCK_ROLE: cloudflare
+ ORIGIN_INTERNAL_URL: http://mock-aa
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.11
+ aliases:
+ - cf.mock.test
+
+ # ----- Mock FlareSolverr external bypasser (profile: bypasser-external) -- #
+ mock-flaresolverr:
+ <<: *mock-build
+ container_name: e2e-mock-flaresolverr
+ profiles: ["bypasser-external"]
+ environment:
+ MOCK_ROLE: flaresolverr
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.12
+
+ # NOTE: the mock `doh` role lives in mock_services.py and is exercised by
+ # tests/download/test_doh_resolver_mock.py (real DoHResolver over localhost HTTP).
+ # An in-stack DoH service is intentionally absent — see README "Known limitations".
+
+ # ----- DNS server (profile: dns-manual) --------------------------------- #
+ coredns:
+ image: coredns/coredns:1.11.1
+ container_name: e2e-coredns
+ profiles: ["dns-manual"]
+ command: ["-conf", "/Corefile"]
+ volumes:
+ - ./dns/Corefile:/Corefile:ro
+ - ./dns/mock.test.db:/zones/mock.test.db:ro
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.20
+
+ # ----- DNS server that NXDOMAINs the AA host (profile: dns-blocked) ------ #
+ # Simulates ISP DNS blocking (#1028); the app must fall back (DoH / direct).
+ coredns-blocked:
+ image: coredns/coredns:1.11.1
+ container_name: e2e-coredns-blocked
+ profiles: ["dns-blocked"]
+ command: ["-conf", "/Corefile"]
+ volumes:
+ - ./dns/Corefile.blocked:/Corefile:ro
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.22
+
+ # ----- HTTP proxy (profile: proxy-http) --------------------------------- #
+ tinyproxy:
+ image: monokal/tinyproxy:latest
+ container_name: e2e-tinyproxy
+ profiles: ["proxy-http"]
+ command: ANY
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.30
+
+ # ----- SOCKS5 proxy (profile: proxy-socks) ------------------------------ #
+ microsocks:
+ image: vimagick/microsocks:latest
+ container_name: e2e-microsocks
+ profiles: ["proxy-socks"]
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.31
+
+ # ----- Mock Prowlarr indexer (full + client-* profiles) ----------------- #
+ # Minimal Prowlarr API returning one torrent release whose .torrent is a
+ # webseed pointing at mock-aa. Drives the real torrent-client download in the
+ # `full` (qBittorrent) and `client-*` (transmission/deluge/rtorrent) profiles.
+ mock-prowlarr:
+ <<: *mock-build
+ container_name: e2e-mock-prowlarr
+ profiles: ["full", "client-transmission", "client-deluge"]
+ environment:
+ MOCK_ROLE: prowlarr
+ AA_INTERNAL_URL: http://mock-aa
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.40
+ aliases:
+ - prowlarr.mock.test
+
+ # ----- Real qBittorrent download client (profile: full) ----------------- #
+ # Auth is bypassed for the e2e subnet (qBittorrent.conf) so shelfmark connects
+ # without juggling the image's random temp password.
+ qbittorrent:
+ image: lscr.io/linuxserver/qbittorrent:latest
+ container_name: e2e-qbittorrent
+ profiles: ["full"]
+ environment:
+ PUID: "1000"
+ PGID: "1000"
+ TZ: UTC
+ WEBUI_PORT: "8080"
+ volumes:
+ - ./qbittorrent/qBittorrent.conf:/config/qBittorrent/qBittorrent.conf
+ # Shared with shelfmark so completed files are visible for the move step.
+ - ./.state/downloads:/downloads
+ networks:
+ - e2e
+
+ # ----- Real Transmission client (profile: client-transmission) ---------- #
+ # Same webseed torrent + mock Prowlarr as `full`, different real client.
+ transmission:
+ image: lscr.io/linuxserver/transmission:latest
+ container_name: e2e-transmission
+ profiles: ["client-transmission"]
+ environment:
+ PUID: "1000"
+ PGID: "1000"
+ TZ: UTC
+ USER: admin
+ PASS: admin
+ volumes:
+ - ./.state/downloads:/downloads
+ networks:
+ - e2e
+
+ # ----- Real Deluge client (profile: client-deluge) ---------------------- #
+ # shelfmark talks to deluge-web (default WebUI password "deluge"), which
+ # connects to the bundled daemon — no daemon auth-file juggling.
+ deluge:
+ image: lscr.io/linuxserver/deluge:latest
+ container_name: e2e-deluge
+ profiles: ["client-deluge"]
+ environment:
+ PUID: "1000"
+ PGID: "1000"
+ TZ: UTC
+ volumes:
+ - ./.state/downloads:/downloads
+ networks:
+ - e2e
+
+ # NOTE: rTorrent is intentionally absent — its rakshasa-libtorrent has no
+ # GetRight/webseed support, so the hermetic webseed torrent can't drive it.
+ # Supporting it would need a real tracker + seeder (see README limitations).
+
+ # ----- DoH-over-HTTPS responder (profile: dns-doh) ---------------------- #
+ # In-stack DoH: serves the DNS JSON API over HTTPS with a self-signed cert.
+ # shelfmark reaches it because the profile (a) maps the DoH provider hostname
+ # to this container via extra_hosts and (b) sets CERTIFICATE_VALIDATION=disabled.
+ mock-doh:
+ <<: *mock-build
+ container_name: e2e-mock-doh
+ profiles: ["dns-doh"]
+ environment:
+ MOCK_ROLE: doh
+ DOH_TLS: "1"
+ DOH_MAP: "aa.mock.test=172.30.0.10,cf.mock.test=172.30.0.11"
+ networks:
+ e2e:
+ ipv4_address: 172.30.0.21
diff --git a/tests/e2e/platform/mocks/Dockerfile b/tests/e2e/platform/mocks/Dockerfile
new file mode 100644
index 0000000..ab179b3
--- /dev/null
+++ b/tests/e2e/platform/mocks/Dockerfile
@@ -0,0 +1,25 @@
+# Mock services image for the Shelfmark e2e platform.
+# One image, many roles (selected via MOCK_ROLE): origin-aa, cloudflare,
+# flaresolverr, prowlarr, doh. See mock_services.py for the role contracts.
+FROM python:3.14-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY mock_services.py .
+# Needed by the origin-aa role to build the `full` profile's webseed .torrent.
+COPY make_webseed_torrent.py .
+COPY fixtures ./fixtures
+
+ENV PORT=80
+ENV MOCK_ROLE=all
+
+# gunicorn keeps the mock responsive under concurrent e2e load.
+RUN pip install --no-cache-dir gunicorn
+EXPOSE 80
+EXPOSE 443
+# DOH_TLS=1 (the `doh` role) self-signs a cert and serves HTTPS on 443; every
+# other role serves plain HTTP on $PORT via gunicorn.
+CMD ["sh", "-c", "if [ \"$DOH_TLS\" = 1 ]; then python mock_services.py; else gunicorn -w 2 -b 0.0.0.0:${PORT} mock_services:app; fi"]
diff --git a/tests/e2e/platform/mocks/fixtures/aa_detail.html b/tests/e2e/platform/mocks/fixtures/aa_detail.html
new file mode 100644
index 0000000..edd302f
--- /dev/null
+++ b/tests/e2e/platform/mocks/fixtures/aa_detail.html
@@ -0,0 +1,15 @@
+
+
+Anna's Archive (mock) - __MD5__
+
+
+
+
+
+
diff --git a/tests/e2e/platform/mocks/fixtures/cf_challenge.html b/tests/e2e/platform/mocks/fixtures/cf_challenge.html
new file mode 100644
index 0000000..7ef05cb
--- /dev/null
+++ b/tests/e2e/platform/mocks/fixtures/cf_challenge.html
@@ -0,0 +1,39 @@
+
+
+
+ Just a moment...
+
+
+
+
+
+
+
+
+
Checking if the site connection is secure
+
Verifying you are human. This may take a few seconds.
+
+
+
+
+
+
diff --git a/tests/e2e/platform/mocks/make_webseed_torrent.py b/tests/e2e/platform/mocks/make_webseed_torrent.py
new file mode 100644
index 0000000..5f24658
--- /dev/null
+++ b/tests/e2e/platform/mocks/make_webseed_torrent.py
@@ -0,0 +1,113 @@
+"""Generate a single-file BitTorrent metainfo (.torrent) with a BEP-19 webseed.
+
+Used by the e2e platform's ``full`` profile so a *real* torrent client
+(qBittorrent) can complete a *real* download hermetically: the torrent carries no
+tracker and a single ``url-list`` webseed pointing at the mock origin's HTTP file
+endpoint, so libtorrent fetches the payload over HTTP — no tracker, peer, or
+seeder container required.
+
+Stdlib-only (the mock image does not install shelfmark). The unit test cross-checks
+this encoder against shelfmark's own ``bencode_decode`` /
+``extract_info_hash_from_torrent`` so a divergence in either is caught.
+"""
+
+from __future__ import annotations
+
+import hashlib
+
+DEFAULT_PIECE_LENGTH = 16384 # 16 KiB — fine for the tiny e2e payload
+
+
+def bencode(value: object) -> bytes:
+ """Minimal bencode encoder (int / bytes / str / list / dict)."""
+ if isinstance(value, bool): # guard: bool is an int subclass
+ raise TypeError("bool is not bencodable")
+ if isinstance(value, int):
+ return b"i" + str(value).encode() + b"e"
+ if isinstance(value, bytes):
+ return str(len(value)).encode() + b":" + value
+ if isinstance(value, str):
+ return bencode(value.encode("utf-8"))
+ if isinstance(value, list):
+ return b"l" + b"".join(bencode(item) for item in value) + b"e"
+ if isinstance(value, dict):
+ out = b"d"
+ for key in sorted(value): # bencode dict keys must be sorted
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
+ out += bencode(key_bytes) + bencode(value[key])
+ return out + b"e"
+ raise TypeError(f"Cannot bencode value of type {type(value).__name__}")
+
+
+def _pieces(data: bytes, piece_length: int) -> bytes:
+ return b"".join(
+ hashlib.sha1(data[i : i + piece_length]).digest() for i in range(0, len(data), piece_length)
+ )
+
+
+def build_info_dict(name: str, data: bytes, piece_length: int = DEFAULT_PIECE_LENGTH) -> dict:
+ return {
+ "name": name,
+ "piece length": piece_length,
+ "length": len(data),
+ "pieces": _pieces(data, piece_length),
+ }
+
+
+def build_webseed_torrent(
+ name: str,
+ data: bytes,
+ webseed_url: str,
+ *,
+ piece_length: int = DEFAULT_PIECE_LENGTH,
+) -> bytes:
+ """Build a tracker-less single-file .torrent whose only source is a webseed.
+
+ Args:
+ name: file name inside the torrent (e.g. ``sample-book.epub``).
+ data: the exact file bytes the webseed URL must serve.
+ webseed_url: BEP-19 url-list entry — the direct HTTP URL for ``data``.
+ """
+ info = build_info_dict(name, data, piece_length)
+ metainfo = {
+ "info": info,
+ # Single-entry webseed. For a single-file torrent the url-list entry is the
+ # direct file URL, so it must serve exactly ``data``.
+ "url-list": [webseed_url],
+ "comment": "shelfmark e2e webseed torrent",
+ "created by": "shelfmark-e2e",
+ }
+ return bencode(metainfo)
+
+
+def info_hash(torrent_bytes: bytes) -> str:
+ """The btih (SHA1 of the bencoded ``info`` dict) as a hex string.
+
+ Re-encodes via a tiny scan so we don't need a full decoder here.
+ """
+ marker = b"4:infod"
+ start = torrent_bytes.find(marker)
+ if start < 0:
+ raise ValueError("no info dict found in torrent")
+ info_start = start + len(b"4:info")
+ # The info value begins at 'd'; find its matching 'e' by bencode-aware scan.
+ end = _scan_bencoded(torrent_bytes, info_start)
+ return hashlib.sha1(torrent_bytes[info_start:end]).hexdigest()
+
+
+def _scan_bencoded(buf: bytes, pos: int) -> int:
+ """Return the index just past the bencoded value starting at ``pos``."""
+ token = buf[pos : pos + 1]
+ if token == b"i":
+ return buf.index(b"e", pos) + 1
+ if token in (b"l", b"d"):
+ pos += 1
+ while buf[pos : pos + 1] != b"e":
+ if token == b"d": # dicts: key then value
+ pos = _scan_bencoded(buf, pos)
+ pos = _scan_bencoded(buf, pos)
+ return pos + 1
+ # byte string: :
+ colon = buf.index(b":", pos)
+ length = int(buf[pos:colon])
+ return colon + 1 + length
diff --git a/tests/e2e/platform/mocks/mock_services.py b/tests/e2e/platform/mocks/mock_services.py
new file mode 100644
index 0000000..de39b23
--- /dev/null
+++ b/tests/e2e/platform/mocks/mock_services.py
@@ -0,0 +1,577 @@
+"""Controllable mock services for the Shelfmark e2e Docker platform.
+
+A single Flask app that plays one of several *roles*, selected by the
+``MOCK_ROLE`` environment variable. Running one image with different roles keeps
+the platform image small and the behaviour in one auditable place.
+
+Roles
+-----
+``origin-aa`` Fake Anna's Archive: search results table, ``/md5/`` detail
+ pages, and a downloadable book file. The HTML mirrors the real
+ selectors the parser depends on (``
`` rows, last-cell
+ distant path, ``get.php?md5=..&key=..`` GET links) so parser
+ drift (#878/#879/#880) is caught here. Supports fault injection
+ via query flags to reproduce historical bugs deterministically.
+``cloudflare`` Cloudflare-protected origin: returns a 403 "Just a moment..."
+ challenge page (with ``cf-mitigated: challenge``) until the
+ request carries a ``cf_clearance`` cookie, then serves the real
+ content. Exercises the app's CF detection + bypasser routing
+ (#284, #226, #202, #1030) without running real CF JS.
+``flaresolverr`` Mock FlareSolverr implementing the ``/v1`` contract. It fetches
+ the requested URL *with* a clearance cookie and returns the
+ solved HTML + cookies, so ``_fetch_via_bypasser`` runs end to
+ end deterministically (no headless Chrome needed in CI).
+``doh`` Minimal DNS-over-HTTPS (RFC 8484 + Google JSON) responder used
+ to verify the USE_DOH path resolves mock domains even when the
+ system resolver is poisoned (#1028).
+``all`` Mounts every role at once (default; handy for local poking).
+
+Fault injection (origin-aa) rides INSIDE the search query as an
+``E2EINJECT:`` token, because the app builds the AA URL itself and only
+forwards the user query as ``q=``. The harness embeds it (see
+PlatformClient.direct_search); the mock strips it before rendering:
+ ``no_files`` -> renders the literal "No files found." alongside a real row
+ (regression for the false-positive check).
+ ``empty`` -> renders an empty results page (true "No files found").
+ ``layout_drift`` -> renders a structurally-changed page (cards, no
) so
+ a hardcoded-index parser yields zero rows — the app must
+ fail loudly, not silently (#878/#879/#880).
+ ``500`` -> returns HTTP 500 (mirror failover path).
+"""
+
+from __future__ import annotations
+
+import base64
+import os
+import re
+import struct
+from pathlib import Path
+
+from flask import Flask, Response, jsonify, make_response, request
+
+FIXTURES = Path(__file__).parent / "fixtures"
+ROLE = os.environ.get("MOCK_ROLE", "all").strip().lower()
+# Hostname the flaresolverr/cloudflare roles use to reach the AA origin from
+# inside the compose network.
+ORIGIN_INTERNAL_URL = os.environ.get("ORIGIN_INTERNAL_URL", "http://mock-aa")
+CLEARANCE_COOKIE = "cf_clearance"
+CLEARANCE_VALUE = "e2e-cleared-token"
+
+# Test book: *Moby-Dick* by Herman Melville (public domain), used both as the
+# webseed-torrent payload (qBittorrent path) and the AA slow-download payload (real
+# Chrome / Cloudflare path). PAYLOAD_NAME/URL must stay in sync between what mock-aa
+# serves and what the torrent's url-list references.
+PAYLOAD_NAME = "moby-dick.epub"
+PAYLOAD_URL = f"{os.environ.get('PAYLOAD_PUBLIC_URL', 'http://mock-aa')}/payload/{PAYLOAD_NAME}"
+
+# Base for AA slow-download links on the detail page. When set to the Cloudflare
+# gate (the `full` profile sets it to http://cf.mock.test), the *download* — not the
+# search — is forced through the gate, so a real download triggers the internal
+# Chrome bypasser to solve the challenge. Empty -> same-origin (no CF).
+SLOW_DOWNLOAD_BASE = os.environ.get("SLOW_DOWNLOAD_BASE", "").rstrip("/")
+
+# Real public-domain opening of Moby-Dick (Chapter 1, "Loomings").
+_MOBY_DICK_TEXT = (
+ "Call me Ishmael. Some years ago—never mind how long precisely—having little "
+ "or no money in my purse, and nothing particular to interest me on shore, I "
+ "thought I would sail about a little and see the watery part of the world. It "
+ "is a way I have of driving off the spleen and regulating the circulation. "
+ "Whenever I find myself growing grim about the mouth; whenever it is a damp, "
+ "drizzly November in my soul; whenever I find myself involuntarily pausing "
+ "before coffin warehouses, and bringing up the rear of every funeral I meet; "
+ "and especially whenever my hypos get such an upper hand of me, that it "
+ "requires a strong moral principle to prevent me from deliberately stepping "
+ "into the street, and methodically knocking people's hats off—then, I account "
+ "it high time to get to sea as soon as I can."
+)
+
+app = Flask(__name__)
+
+
+def _payload_bytes() -> bytes:
+ """Deterministic *Moby-Dick* EPUB used as the download payload.
+
+ Determinism matters: the webseed torrent's piece hashes are computed from these
+ exact bytes, so any drift between what mock-aa serves and what the torrent
+ describes would make qBittorrent never complete. Fixed ZipInfo timestamps keep
+ the bytes byte-stable across runs.
+ """
+ import io
+ import zipfile
+
+ files = [
+ ("mimetype", "application/epub+zip"),
+ (
+ "META-INF/container.xml",
+ ''
+ '',
+ ),
+ (
+ "OEBPS/content.opf",
+ ''
+ 'e2e-moby-dick'
+ "Moby-Dick; or, The Whale"
+ "Herman Melville"
+ "en"
+ ''
+ '',
+ ),
+ (
+ "OEBPS/chapter1.xhtml",
+ ''
+ 'Loomings'
+ "
Chapter 1. Loomings.
"
+ # Repeat the opening so the EPUB clears shelfmark's 10 KB minimum-size
+ # check on the direct-download path (_MIN_VALID_FILE_SIZE).
+ + ("
" + _MOBY_DICK_TEXT + "
") * 24
+ + "",
+ ),
+ ]
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
+ for name, content in files:
+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
+ zf.writestr(info, content)
+ return buf.getvalue()
+
+
+def _read_fixture(name: str) -> str:
+ return (FIXTURES / name).read_text(encoding="utf-8")
+
+
+# --------------------------------------------------------------------------- #
+# Role: origin-aa (fake Anna's Archive)
+# --------------------------------------------------------------------------- #
+def _search_rows() -> str:
+ """One real result row in the exact shape the parser expects."""
+ return _read_fixture("aa_search_results.html")
+
+
+def register_origin_aa(flask_app: Flask) -> None:
+ @flask_app.route("/search")
+ def aa_search() -> Response:
+ query = request.args.get("q", "")
+ # Fault injection travels INSIDE the search query (the app builds the AA
+ # URL itself and won't forward arbitrary params), via a token the harness
+ # embeds: "E2EINJECT:".
+ inject = ""
+ match = re.search(r"E2EINJECT:(\w+)", query)
+ if match:
+ inject = match.group(1)
+ query = re.sub(r"E2EINJECT:\w+\s*", "", query).strip()
+ if inject == "500":
+ return make_response("upstream error", 500)
+ if inject == "empty":
+ body = _read_fixture("aa_search_empty.html")
+ return make_response(body, 200)
+ if inject == "no_files":
+ # Real row present *and* the "No files found." string — the historical
+ # false-positive (#: 'No files found' check). The app must still
+ # surface the real row.
+ body = _search_rows().replace("", "
No files found.
")
+ return make_response(body, 200)
+ if inject == "layout_drift":
+ return make_response(_read_fixture("aa_search_layout_drift.html"), 200)
+ # Echo the query into the title so tests can assert routing worked.
+ body = _search_rows().replace("__QUERY__", query or "A Book Title")
+ return make_response(body, 200)
+
+ @flask_app.route("/md5/")
+ def aa_detail(book_id: str) -> Response:
+ # __SLOW_BASE__ controls where the AA "slow partner server" links point.
+ # In the `full` profile it's the Cloudflare gate, so the *download* (not the
+ # search/detail) is what forces the internal Chrome bypasser to solve CF.
+ body = (
+ _read_fixture("aa_detail.html")
+ .replace("__MD5__", book_id)
+ .replace("__SLOW_BASE__", SLOW_DOWNLOAD_BASE)
+ )
+ return make_response(body, 200)
+
+ @flask_app.route("/get.php")
+ def aa_getphp() -> Response:
+ # The actual file download link target (get.php?md5=..&key=..).
+ return _serve_book()
+
+ @flask_app.route("/slow_download/")
+ def aa_slow(rest: str) -> Response:
+ # The AA "slow partner server" page: an HTML page whose "Download now" link
+ # is the final file URL. shelfmark's _extract_slow_download_url parses this.
+ # Reached (in the full profile) only after the internal Chrome bypasser
+ # solves the Cloudflare gate in front of it.
+ del rest
+ file_url = f"{os.environ.get('AA_FILE_BASE', 'http://mock-aa')}/file/{PAYLOAD_NAME}"
+ # The visible text must exceed the internal bypasser's "still loading"
+ # threshold (_LOADING_BODY_LENGTH_MAX = 50 chars of body.innerText) or it
+ # never considers the (cleared) page settled and loops until timeout.
+ html = (
+ "Download"
+ "
Anna’s Archive — Slow Partner Server
"
+ "
Your download of Moby-Dick; or, The Whale by Herman Melville "
+ "is ready. Use the link below to download the file from this slow partner "
+ "server. The connection is slow but free, with no waitlist.