Pytest-based regression and acceptance tests, plus fuzz and benchmark suites for the Apache module.
tests/
├── run # dispatcher — runs pytest with sane defaults
├── pyproject.toml # pytest config + editable-install metadata
├── requirements-test.txt # pinned deps (installed into tests/.venv)
├── conftest.py # pytest fixtures (apache, fresh_ip, log_slice, …)
├── README.md # (this file)
│
├── botshield_test/ # framework helpers (importable Python package)
│ ├── apache.py # reload/restart + transactional config_override
│ ├── client.py # httpx wrapper with bs-specific defaults
│ ├── config.py # paths + constants, env-var overrides
│ ├── cookies.py # pending cookie, PoW solver, tamper helpers
│ ├── enums.py # TIERS / OUTCOMES / COOKIES / PROVIDERS
│ ├── ips.py # time-salted fresh_ip() + rate-slot flavor
│ ├── load.py # rate-limited load generator (soak + fixtures)
│ ├── logs.py # log_slice + structured decision parser + validator
│ ├── metrics.py # /metrics snapshot + delta
│ └── providers.py # per-captcha-provider specs (quirks as data)
│
├── pytests/ # 55 test files (test_*.py); assets/axe.min.js for a11y
│ ├── test_acceptance_*.py # end-to-end user journeys
│ ├── test_app_*.py # X-BotShield-Feedback / -Claims (E5/E8.2)
│ ├── test_browser_a11y.py # Playwright accessibility smoke
│ ├── test_capacity_metrics.py # SHM headroom (E13.1)
│ ├── test_captcha_*.py # M8 + M8.1 verify endpoint
│ ├── test_cookie_*.py # cookie format + verify + property tests
│ ├── test_form_captcha.py # E18 inline form captcha
│ ├── test_load_*.py # E11 load-aware throttling
│ ├── test_robots.py # E2.2 RFC 9309 + Crawl-delay
│ ├── test_safeguard.py # E10 anti-loop
│ ├── test_secret_rotation.py # E16
│ ├── test_shadow_mode.py # E12
│ ├── test_soak.py # @slow + @serial soak runner
│ ├── test_triggers.py # E3/E4/E6/E7/E14 trigger families
│ ├── test_namespace.py # E13 multi-vhost isolation
│ └── … # plus ~30 more
│
├── site/ # dev-vhost docroot (committed; 4 minimum fixtures)
│ ├── index.html # pass-through baseline (no __bsChallenge marker)
│ ├── bs-custom-help.html
│ ├── bs-custom-page.html
│ └── assets/logos/01-guardian.svg
│
├── fuzz/ # LibFuzzer harnesses (M11.8)
│ ├── _fuzz_stubs.h # minimal Apache runtime stubs
│ ├── fuzz_cookie.c # harness for bs_verify_cookie
│ ├── fuzz_robots.c # harness for the robots.txt parser
│ ├── seed_corpus.py # populates corpus/ with real cookies
│ ├── seeds-robots/ # checked-in robots.txt seed corpus
│ └── run.sh # `run.sh --target {cookie|robots} <seconds>`
│
├── bench/ # benchmark suite (out of pytest)
│ ├── run-bench.sh # wrk saturation sweep across 12 scenarios
│ ├── run-rate-bench.sh # oha fixed-rate sweep at 1k/5k/10k RPS
│ ├── scenarios/ # 12 vhost configs from baseline → kitchen-sink
│ ├── bench.html # tiny static target served by the bench docroot
│ ├── bench-robots.txt # robots.txt for the bench scenarios
│ ├── wrk-cookie.lua # cookie attacher for the cookied scenario
│ └── scripts/ # mint_cookie.py, helpers
│
└── setup/
├── provision.sh # idempotent one-shot box setup (creates .venv)
├── reset-state.sh # between-run state-file wipe
└── sudoers.d.example # template for scoped passwordless apachectl
The target is Ubuntu 24.04 on a dev box with sudo. Debian 12 and Ubuntu 22.04 work identically. The setup script installs everything:
apache2,apache2-devlibssl-dev,libcurl4-openssl-dev,libjson-c-dev,libpcre2-devpython3,python3-venv(pytest framework lives intests/.venv)curl,openssl- Chromium runtime libs (libnss3, libatk, libxkbcommon, …) for Playwright
Pinned Python deps (requirements-test.txt): httpx, pytest,
pytest-xdist, pytest-timeout, pytest-playwright, playwright,
pytest-rerunfailures, pytest-html, hypothesis,
prometheus_client. provision.sh creates tests/.venv, installs
them, pulls Chromium into ~/.cache/ms-playwright, and apt-installs
the matching shared-lib dependencies.
RHEL-family isn't scripted but the dependency list maps cleanly:
httpd-devel, openssl-devel, libcurl-devel, json-c-devel,
pcre2-devel, python3.
From a fresh box with the repo checked out:
sudo tests/setup/provision.shThe script is idempotent — safe to re-run. On first run it:
- Refuses to proceed if the repo path is group/world-writable (preempts a make-install hijack on shared boxes).
- Installs apt packages.
- Builds the module (
make) and installs it (/usr/lib/apache2/modules/mod_botshield.so). - Creates a self-signed cert at
/etc/ssl/botshield-dev/(for the HTTPS dev vhost on localhost). - Generates
/etc/botshield/secret(HMAC key, 32 bytes hex) if it doesn't exist,chmod 600. Same for the per-provider dummy secret files (/etc/botshield/{turnstile,hcaptcha,recaptcha-v2,recaptcha-v3, friendly,geetest}-secret) and the sharedapp-integration-secretfor E5/E8.2. - Creates
/var/lib/botshield/owned bywww-datafor the state file, plus seeds/var/lib/botshield/bots/fromapache/bots/*.txt(Googlebot / Bingbot / Applebot CIDR ranges). - Stages
/etc/botshield/test-robots/(writable by the test user, readable by Apache — works aroundPrivateTmp=truefor E2.2 tests) and/etc/botshield/load.state.test(initial valuenormal, for E11.1 tests). - Installs
apache/botshield-dev.confas the enabled site, enablesmod_botshield,mod_status,mod_remoteip,mod_ssl,mod_headers. Selectsmpm_eventas the default MPM. apachectl configtestand reloads.
If you later modify the module source, make && sudo make install && sudo systemctl reload apache2 is enough — you don't need to re-run
provision.sh.
tests/setup/sudoers.d.example is a template for granting the
test user passwordless apachectl reload / systemctl restart apache2 so the test runner doesn't prompt mid-run. Operator-
optional; the suite works without it (sudo will prompt once per
boot).
tests/run # pytest suite, default markers
tests/run --parallel # xdist parallel (non-serial tests); ~20% faster
tests/run --slow # include @slow tests (soak, periodic_save)
tests/run --match cookie # substring filter; passed to pytest as -k
tests/run --mark "not browser" # pytest -m marker expression
tests/run --verbose # pytest -v instead of -q
tests/run --list # show tests that would run, don't execute
# Soak specifically (60s / 25 rps by default):
tests/run --slow --match soak
# Overnight soak (8h / 50 rps):
BS_SOAK_DURATION_SEC=28800 BS_SOAK_RPS=50 \
tests/run --slow --match soakExit code is 0 if every test passed (or was skipped), 1 if any
test failed.
You can also invoke pytest directly — useful for iterating on one
test with pytest's full traceback + --pdb:
tests/.venv/bin/pytest tests/pytests/test_cookie_gcm.py -v
tests/.venv/bin/pytest tests/pytests/ -k "captcha and not rejected"
tests/.venv/bin/pytest tests/pytests/ -m "not serial" -n autoDefined in pyproject.toml; consumed by tests/run and the GitHub
Actions workflow.
@pytest.mark.serial— mutates shared Apache state (config swap, SHM restart). Runs outside the xdist pool.@pytest.mark.slow— multi-second wait (e.g. the 40-second watchdog tick). Excluded by default; opt in withtests/run --slow.@pytest.mark.live_network— requires reachable third-party captcha siteverify (Cloudflare, Google, hCaptcha, Friendly, GeeTest). Skips if unreachable rather than failing. Auto-retries twice viapytest-rerunfailures(applied through a collection hook inconftest.py— new live_network tests inherit the policy automatically).@pytest.mark.live_provider— requires a real provider secret via env var. Skips without the env var.@pytest.mark.acceptance— end-to-end user-journey test.@pytest.mark.browser— runs in a real headless Chromium via pytest-playwright. Catches regressions no request library can see (interstitial JS execution, cookie attribute enforcement, auto-submit form wiring).
Every pytest invocation writes two reports to tests/reports/:
pytests-<phase>.xml— JUnit XML, picked up by GitHub Actions' native test-summary view.pytests-<phase>.html— self-contained HTML, downloadable as a CI artifact for triage when a test fails.
<phase> is not_serial / serial under --parallel, or all
otherwise. The directory is gitignored.
Three providers don't publish iconic always-pass keys, so the matching tests skip unless an operator supplies real credentials:
| Provider | Env vars | Source |
|---|---|---|
| Friendly Captcha | BS_FRIENDLY_SECRET, BS_FRIENDLY_SITEKEY, BS_FRIENDLY_SOLUTION |
friendlycaptcha.com free tier |
| GeeTest v4 | BS_GEETEST_KEY, BS_GEETEST_ID, BS_GEETEST_TOKEN |
dashboard.geetest.com |
| reCAPTCHA v3 (real score) | BS_RECAPTCHA_V3_SECRET, BS_RECAPTCHA_V3_SITEKEY, BS_RECAPTCHA_V3_TOKEN |
reCAPTCHA admin |
Tests that need these print SKIP: <reason> if the env var isn't set,
so the suite passes without them. To run those tests end-to-end:
export BS_FRIENDLY_SECRET="..."
export BS_FRIENDLY_SITEKEY="..."
export BS_FRIENDLY_SOLUTION="..."
tests/run --match friendlyCI passes the _TOKEN / _SOLUTION flavors as repo secrets to the
test-fast job (see .github/workflows/ci.yml).
Published provider dummies (committed in provision.sh):
| Provider | Always-pass sitekey | Always-pass secret |
|---|---|---|
| Turnstile | 1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
| hCaptcha | 10000000-ffff-ffff-ffff-000000000001 |
0x0000000000000000000000000000000000000000 |
| reCAPTCHA v2 | 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI |
6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe |
Two coverage-guided LibFuzzer harnesses, each ASan + UBSan + libFuzzer. Not in the default pytest suite — fuzzing wants minutes- to-hours budgets, not per-PR budgets. Run manually:
sudo apt install -y clang libclang-rt-dev # one-time
make fuzz # builds tests/fuzz/fuzz_cookie
make fuzz-robots # builds tests/fuzz/fuzz_robots
tests/fuzz/run.sh --target cookie 30 # 30-second smoke
tests/fuzz/run.sh --target cookie 3600 # 1-hour campaign
tests/fuzz/run.sh --target robots 1800 # 30-minute robots-parser campaignA crash, UB report, or leak produces a crash-<hash> /
leak-<hash> / timeout-<hash> / slow-unit-<hash> file next to
the binary — those are the minimized reproducer inputs. The
workflow_dispatch-only fuzz-nightly CI job runs both harnesses
sequentially (30 m each) and uploads artifacts on a finding.
The hypothesis-driven byte-tamper test in
pytests/test_cookie_property.py covers the cookie parser at ~50
exec/sec via HTTP; LibFuzzer here covers it at 200k+ exec/sec
in-process with coverage feedback. Complementary, not redundant —
HTTP hypothesis catches end-to-end issues (cookies the module
accepts it shouldn't); LibFuzzer catches memory safety / UB bugs
in the parsing C.
Two complementary suites under tests/bench/. Out of the pytest
runner — these are operator-driven measurements, not pass/fail
gates.
tests/bench/run-bench.sh # wrk saturation: 12 scenarios × 30s
tests/bench/run-rate-bench.sh # oha fixed-rate: 1k/5k/10k RPS sweeprun-bench.sh answers "what's the capacity ceiling?" — useful for
sizing but produces deltas amplified by the cheap static-file
denominator. run-rate-bench.sh answers "at production-realistic
load, how much latency does BotShield add?" — better proxy for
real-world cost. Both write JSON results under
tests/bench/results/<timestamp>-…/. The directory is gitignored
and growing — wipe periodically with rm -rf tests/bench/results/
if it gets unwieldy.
See docs-src/deployment.md "Performance characteristics" for the
canonical numbers.
- Absolute counter values. Always work in deltas:
metrics.snapshot()before, drive traffic,metrics.snapshot()after, thenmetrics.delta(before, after). Counters may be any non-negative value at test start. - Empty flagged-IP table. Prior runs may have flagged IPs that
persist in the state file. Use
rate_slot_ip(a fresh, un-flagged IP per call) or request theclean_statefixture to wipe the state file + restart. - Fresh Bloom filter. Same reason. Use
fresh_ip— the allocator picks from100.64.0.0/10(CGN), which no earlier run has touched. - Log position. Use the
log_slicefixture to extract only this test's lines from the botshield error log.
Most tests use deltas and don't need a reset. For tests that DO
need a known-clean SHM + state file, request the clean_state
fixture:
def test_fresh_setup(clean_state, fresh_ip):
# SHM + state file are empty. Apache just finished restarting.
...Or from the shell:
sudo tests/setup/reset-state.shWhen a test fails, pytest prints the traceback with surrounding source. Additional forensics:
- Module errors:
sudo tail -50 /var/log/apache2/error.log - Decision log:
sudo tail -50 /var/log/apache2/botshield-dev-error.log - Access log:
sudo tail -50 /var/log/apache2/botshield-dev-access.log - Current SHM counters:
curl -sk https://localhost/botshield/metrics - Current mod_status:
curl -sk https://localhost/server-status - Drop into pdb on failure:
tests/.venv/bin/pytest pytests/test_X.py --pdb
config_override guarantees revert on exception, so a test that
blows up mid-swap leaves the vhost in its pre-test state. If the box
is somehow left in an odd state anyway,
sudo tests/setup/provision.sh restores the baseline.
Put the file under tests/pytests/, named test_<something>.py.
Request the fixtures you need by parameter name — no imports.
"""One-line description of what this test proves."""
from botshield_test import client, metrics
def test_some_path_emits_challenged(fresh_ip, log_slice):
before = metrics.snapshot()
with log_slice as slc:
client.get("/some-path", xff=fresh_ip, ua="python-requests/2.31")
lines = slc.decision_lines(ip=fresh_ip, outcome="challenged")
after = metrics.snapshot()
assert len(lines) == 1
deltas = metrics.delta(before, after)
assert deltas["botshield_outcome_challenged_total"] == 1Markers (declared at module level via pytestmark = pytest.mark.serial or per-test via @pytest.mark.live_network)
are documented in the "Markers" section above.