From c5fbbd753f60503df39a4e6dbd2f55898d5d46bc Mon Sep 17 00:00:00 2001 From: taylanbakircioglu Date: Sat, 15 Aug 2026 11:04:31 +0300 Subject: [PATCH] test(requestlog): pin the real payloads and the fleet-scale behaviour The branch shipped 245 tests and 72 of them covered redaction, all passing, while six real endpoints of this application still wrote secrets to `request_logs`. That is not a gap in effort, it is a gap in kind: those tests pin the RULES - which key names match, which value shapes fire - and a rule test proves the rule, not the coverage. Nothing was measuring what this system actually sends. 51 tests in two files, every case built from a real handler's request or response shape with the field names taken from the source and cited in the docstring. test_request_log_real_payloads.py drives payloads through `decode_body()`, the same entry point the writer uses, rather than calling `redact()` on a dict. That is load-bearing: a config upload is routinely larger than the capture cap, so it never reaches redaction as a dict at all - it arrives as one truncated `_raw` string where the line breaks are still the escape `\n`. A test that starts from a dict reports a pass on a payload that leaks, and on one that gets masked into uselessness. Both properties are asserted on both paths: the secret is gone AND the rest of the config is still readable. test_request_log_fleet_scale.py pins the four behavioural fixes, each of which only appears at scale or at the edge of a setting's documented range: * successful agent polls are dropped and failures never are, including a transport error with no HTTP response at all; * agent traffic is identified from headers, and `offer()` is asserted to contain no `await` and no connection call, because it runs on the request coroutine; * `requestlog.read` scoping admits agent rows but NOT `user_id IS NULL`, so anonymous traffic and the usernames in failed logins stay admin-only; * background passes get one id each, and unwrapped background code does not collapse onto one either; * queue memory stays inside its budget with `max_body_bytes` at its 256 KB ceiling, and the budget is released as rows drain - a budget that only counts up is a leak, not a limit. Also closes a hole in the branch's own auth tests: they asserted that every endpoint calls `_require`, but not that it is called BEFORE the try block. The repo's GHSA-3p5c pattern exists because a permission check inside `try` is swallowed by the handler's `except Exception -> 500`, which turns a 403 into a server error and hides that the check ran. Now asserted per endpoint. Both halves of each trade are pinned: alongside every "this must be redacted" there is an "and this must not be", so a later tightening cannot quietly blank the fields the feature exists to show. --- backend/tests/test_request_log_fleet_scale.py | 298 ++++++++++++++++++ .../tests/test_request_log_real_payloads.py | 289 +++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 backend/tests/test_request_log_fleet_scale.py create mode 100644 backend/tests/test_request_log_real_payloads.py diff --git a/backend/tests/test_request_log_fleet_scale.py b/backend/tests/test_request_log_fleet_scale.py new file mode 100644 index 0000000..24eb391 --- /dev/null +++ b/backend/tests/test_request_log_fleet_scale.py @@ -0,0 +1,298 @@ +"""v1.11.0: the log's cost must follow operator activity, not fleet size. + +Every property here was a real defect measured on the feature branch, and each +one only shows up at scale or at the edge of a setting's documented range, which +is why none of them were caught by the rule-level tests. + + * one row per API call becomes millions per day once the fleet is a few + hundred nodes, and the row cap then evicts the forensic history the feature + exists for; + * the operator role could not see the rows its grant was written for; + * every background call ever made shared one correlation id; + * queue memory was a function of an operator-editable setting, not a limit. +""" +import asyncio +import os +import re +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dataclasses import replace # noqa: E402 + +from utils.http_instrumentation import _correlation_id, begin_background_trace # noqa: E402 +from utils.request_log_settings import ( # noqa: E402 + DEFAULT_CONFIG, + get_config, + set_config, +) +from utils.request_log_sink import ( # noqa: E402 + TARGET_INBOUND_AGENT, + RequestLogRow, + RequestLogSink, + request_id_context, +) + +_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_ROUTER = os.path.join(_BACKEND, "routers", "request_logs.py") +_MIDDLEWARE = os.path.join(_BACKEND, "middleware", "request_logger.py") + + +@pytest.fixture(autouse=True) +def _restore_config(): + """These tests mutate the module-global snapshot; put it back.""" + before = get_config() + yield + set_config(before) + + +def _row(**kw): + kw.setdefault("request_id", "a" * 32) + kw.setdefault("direction", "inbound") + kw.setdefault("method", "GET") + kw.setdefault("url", "/api/agents/prod-lb-1/config") + kw.setdefault("status_code", 200) + return RequestLogRow(**kw) + + +class _CountingSink(RequestLogSink): + """Counts what survives `offer()` without needing an event loop.""" + + def __init__(self, **kw): + super().__init__(kw.pop("maxsize", 10000), 100, 500, **kw) + self.accepted = [] + + def _ensure_queue(self): + sink = self + + class _Q: + def put_nowait(self, row): + sink.accepted.append(row) + + def qsize(self): + return len(sink.accepted) + + return _Q() + + +# -------------------------------------------------------------------------- +# Volume: successful agent polls are not rows +# -------------------------------------------------------------------------- + +def test_successful_agent_polls_are_dropped_by_default(): + """~9 800 rows/day PER AGENT, all of them 200s meaning "nothing changed". + + At 200 nodes that is ~2M rows/day and the 500 000 row cap is reached in + about six hours, so the configured "7 days of successes, 30 days of + failures" silently becomes about six hours of each — for everything in the + table, not just for the agent rows. + """ + assert DEFAULT_CONFIG.capture_agent_success is False, ( + "the default must be off; on, the table's size is a function of node " + "count rather than of anything anyone did" + ) + sink = _CountingSink() + for _ in range(100): + sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=200)) + assert sink.accepted == [] + + +@pytest.mark.parametrize("status", [401, 422, 500, None]) +def test_failed_agent_calls_are_always_kept(status): + """The half an operator actually needs, and rare enough to be free. + + `None` is a transport error with no HTTP response at all, which + status_class reports as 0. + """ + sink = _CountingSink() + sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=status)) + assert len(sink.accepted) == 1, f"a {status} agent call must be recorded" + + +def test_operator_traffic_is_unaffected_by_the_agent_gate(): + sink = _CountingSink() + sink.offer(_row(target=None, status_code=200, user_id=7)) + assert len(sink.accepted) == 1 + + +def test_the_gate_can_be_turned_on_for_debugging(): + set_config(replace(get_config(), capture_agent_success=True)) + sink = _CountingSink() + sink.offer(_row(target=TARGET_INBOUND_AGENT, status_code=200)) + assert len(sink.accepted) == 1 + + +def test_agent_traffic_is_identified_by_headers_not_by_a_database_lookup(): + """The hot path runs on every request; a lookup per call is not affordable. + + The installed agent sends `X-API-Key` and never `Authorization`; the UI + sends a JWT and never an agent key. + """ + from middleware.request_logger import _is_agent_call + + def scope(headers): + return {"type": "http", "headers": [(k.encode(), v.encode()) for k, v in headers.items()]} + + assert _is_agent_call(scope({"x-api-key": "agt_x"})) is True + assert _is_agent_call(scope({"authorization": "Bearer x.y.z"})) is False + # generate-install-script accepts either; self-upgrade sends only the key. + assert _is_agent_call(scope({"authorization": "Bearer x.y.z", "x-api-key": "agt_x"})) is False + assert _is_agent_call(scope({})) is False + + +def test_agent_gate_does_not_reach_for_a_connection(): + """`offer()` is called from the request coroutine and must stay pure.""" + src = open(os.path.join(_BACKEND, "utils", "request_log_sink.py"), encoding="utf-8").read() + body = src.split("def offer(", 1)[1].split("\n # -- consumer", 1)[0] + for forbidden in ("await ", "get_database_connection", "fetch"): + assert forbidden not in body, f"offer() must not {forbidden.strip()!r} — it runs on the hot path" + + +# -------------------------------------------------------------------------- +# Visibility: the operator grant has to mean something +# -------------------------------------------------------------------------- + +def test_read_only_scoping_admits_agent_rows_but_not_other_users(): + """`operator` holds requestlog.read to "debug failing applies" — but an + apply fails on the NODE, and the node reports over its own API key, so that + row has user_id NULL and own-rows-only scoping hid it. + + Keyed on `target`, NOT on `user_id IS NULL`: anonymous traffic (failed + logins and their usernames, unauthenticated probes) is not agent traffic + and must stay admin-only. + """ + src = open(_ROUTER, encoding="utf-8").read() + clause = re.search(r"if not can_manage:(.*?)where_sql =", src, re.S) + assert clause, "the self-scoping block moved; re-check this test" + body = clause.group(1) + assert "TARGET_INBOUND_AGENT" in body, "agent rows are still hidden from requestlog.read" + assert "user_id IS NULL" not in body, ( + "scoping on NULL would also expose anonymous traffic, including failed " + "logins and the usernames they carry" + ) + + +def test_detail_endpoint_uses_the_same_scoping_rule_as_the_list(): + src = open(_ROUTER, encoding="utf-8").read() + detail = src.split('@router.get("/{log_id}")', 1)[1] + assert "TARGET_INBOUND_AGENT" in detail, ( + "the detail endpoint would 404 on the very rows the list now shows" + ) + + +@pytest.mark.parametrize("decorator", [ + '@router.get("/settings")', '@router.put("/settings")', + '@router.get("/stats")', '@router.post("/purge")', + '@router.get("")', '@router.get("/{log_id}")', +]) +def test_permission_is_enforced_before_the_try_block(decorator): + """The repo's GHSA-3p5c pattern: a permission check inside `try` gets + swallowed by the handler's own `except Exception -> 500`, turning a 403 + into a server error and, worse, hiding that the check ran at all.""" + src = open(_ROUTER, encoding="utf-8").read() + body = src.split(decorator, 1)[1] + body = body.split("\n@router.")[0] + require_at = body.find("_require(authorization") + try_at = body.find("\n try:") + assert require_at != -1, f"{decorator} does not call _require at all" + assert try_at == -1 or require_at < try_at, ( + f"{decorator} checks permissions INSIDE its try block" + ) + + +# -------------------------------------------------------------------------- +# Correlation: a trace that groups the wrong rows is worse than no trace +# -------------------------------------------------------------------------- + +def test_each_background_pass_gets_its_own_correlation_id(): + """Nothing in main.py names its tasks, so the old `bg:` fallback + gave one long-lived loop a single id for its entire life — measured, 15 + ACME calls across 5 ticks came out as 1 id. `related` (LIMIT 100) then + presents up to a hundred unrelated calls as this request's trace. + """ + async def loop(): + per_tick = [] + for _ in range(5): + begin_background_trace("acme_renewals") + per_tick.append([_correlation_id() for _ in range(3)]) + await asyncio.sleep(0) + return per_tick + + ticks = asyncio.run(loop()) + for tick in ticks: + assert len(set(tick)) == 1, "calls within one pass must share an id" + ids = [t[0] for t in ticks] + assert len(set(ids)) == 5, f"passes must not share an id, got {ids}" + + +def test_unwrapped_background_code_does_not_collapse_onto_one_id(): + """Erring toward too little grouping: a row that stands alone is honest, a + row falsely grouped with a hundred others is not.""" + async def unwrapped(): + request_id_context.set(None) + return [_correlation_id() for _ in range(4)] + + ids = asyncio.run(unwrapped()) + assert len(set(ids)) == 4 + + +def test_the_background_loops_that_make_outbound_calls_open_a_trace(): + src = open(os.path.join(_BACKEND, "main.py"), encoding="utf-8").read() + for loop_name in ("complete_pending_acme_orders", "check_letsencrypt_renewals", + "monitor_agent_status"): + body = src.split(f"async def {loop_name}", 1)[1].split("\nasync def ")[0] + assert "begin_background_trace(" in body, ( + f"{loop_name} makes outbound calls but never opens a per-pass trace" + ) + + +# -------------------------------------------------------------------------- +# Memory: a limit, not a setting +# -------------------------------------------------------------------------- + +def test_queue_memory_is_bounded_even_at_the_max_body_size_ceiling(): + """`max_body_bytes` is editable from Settings and its documented ceiling is + 256 KB, which a row carries twice. Against the default 2 000-row queue that + is ~1 GiB — the entire pod limit — reachable from in-range values. + """ + set_config(replace(get_config(), max_body_bytes=262144, capture_agent_success=True)) + budget = 8 * 1024 * 1024 + sink = _CountingSink(maxsize=2000, max_bytes=budget) + blob = b"x" * 262144 + for _ in range(2000): + sink.offer(_row(target=None, request_body_raw=blob, response_body_raw=blob)) + + held = sum(r.queue_weight() for r in sink.accepted) + assert held <= budget, f"queue held {held} bytes against a {budget} byte budget" + assert sink.stats["dropped"] > 0, "over-budget rows must be dropped, and counted" + unbounded = 2000 * (2 * 262144 + 1400) + assert held < unbounded / 10, ( + f"without the byte budget this queue would hold {unbounded // 1024 // 1024} MiB" + ) + + +def test_the_byte_budget_is_released_as_rows_drain(): + """A budget that only ever counts up is a slow leak, not a limit.""" + async def drain(): + sink = RequestLogSink(2000, 100, 10, max_bytes=8 * 1024 * 1024) + blob = b"x" * 4096 + set_config(replace(get_config(), capture_agent_success=True)) + for _ in range(50): + sink.offer(_row(target=None, request_body_raw=blob, response_body_raw=blob)) + assert sink.stats["queued_bytes"] > 0 + await sink._collect() + return sink.stats["queued_bytes"] + + assert asyncio.run(drain()) == 0 + + +def test_stats_say_the_sink_counters_are_per_worker(): + """The sink is a module global; with UVICORN_WORKERS > 1 each process keeps + its own. A number that looks fleet-wide but is not understates drops by + exactly the worker count.""" + src = open(_ROUTER, encoding="utf-8").read() + assert '"scope"' in src.split('"sink"', 1)[1][:400], ( + "the stats response must label the sink counters as this-worker-only" + ) diff --git a/backend/tests/test_request_log_real_payloads.py b/backend/tests/test_request_log_real_payloads.py new file mode 100644 index 0000000..061e5d5 --- /dev/null +++ b/backend/tests/test_request_log_real_payloads.py @@ -0,0 +1,289 @@ +"""v1.11.0: redaction pinned against THIS codebase's real payloads. + +test_request_log_redaction.py pins the RULES — which key names match, which +value shapes fire. It passed 72/72 while six real endpoints of this application +still wrote secrets to `request_logs`, because a rule test proves the rule, not +the coverage. Every case here is built from an actual handler's request or +response shape, with the field names taken from the source and named in the +docstring, so a future change to redaction is measured against what this system +actually sends rather than against what someone remembered to imagine. + +Method note: the payloads go through `decode_body()`, the same entry point the +writer task uses, rather than calling `redact()` directly. That is deliberate — +two of the findings below only appear on the way in (an oversized body never +reaches `redact()` as a dict at all, it arrives as one `_raw` string), so a test +that starts from a dict would report a pass on a payload that leaks in +production. +""" +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.request_log_redaction import ( # noqa: E402 + decode_body, + is_secret_key, + redact_headers, +) + +VRRP_SECRET = "S3cr3tVrrpPass!" +TOTP_SECRET = "JBSWY3DPEHPK3PXP" +STATS_PASSWORD = "StatsPa55word" +USERLIST_HASH = "$6$rounds=5000$abcdefgh$XyZ" +JWT = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiJ9" + ".dQw4w9WgXcQdQw4w9WgXcQdQw4w9WgXcQ" +) +PEM_KEY = ( + "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA" + "A" * 200 + "\n" + + "-----END RSA PRIVATE KEY-----\n" +) + +# The rendered file `GET /api/agents/{n}/keepalived-config` hands to an agent, +# and the one `POST /api/agents/{n}/keepalived-discovery` sends back. +KEEPALIVED_CONF = f"""! Managed by HAProxy OpenManager +vrrp_instance VI_1 {{ + state MASTER + interface eth0 + virtual_router_id 51 + priority 200 + advert_int 1 + authentication {{ + auth_type PASS + auth_pass {VRRP_SECRET} + }} + virtual_ipaddress {{ + 10.20.30.40/24 + }} +}} +""" + +# A production haproxy.cfg as the agent uploads it verbatim from the node +# (`config_content=$(cat "$config_path")` -> POST .../config-response). +HAPROXY_CFG = f"""global + log stdout local0 + stats socket /var/run/haproxy.sock mode 660 + +userlist admins + user ops password {USERLIST_HASH} + user dev insecure-password Hunter2Plain + +listen stats + bind *:8404 + stats enable + stats auth admin:{STATS_PASSWORD} + stats uri /stats + +backend web + server web1 10.0.0.1:80 check +""" + + +def _capture(payload, *, cap=8192, content_type="application/json"): + """Run a payload through the capture path exactly as the writer does. + + `cap` is `requestlog.max_body_bytes`. Bodies larger than it arrive + truncated, do not parse as JSON, and land in the `{"_raw": ...}` fallback — + which is the common case for config uploads and the case a dict-based test + never exercises. + """ + body = json.dumps(payload).encode() + value, truncated = decode_body(body[:cap], content_type, len(body)) + return json.dumps(value, default=str), truncated + + +def _assert_absent(rendered, *secrets): + for secret in secrets: + assert secret not in rendered, ( + f"{secret!r} reached request_logs. Rendered row: {rendered[:400]}" + ) + + +# -------------------------------------------------------------------------- +# The VRRP password. routers/vip.py: "the secret never leaves the server in +# cleartext ... only the at-rest Fernet token and the agent-delivery endpoint +# ever see the real value." +# -------------------------------------------------------------------------- + +def test_vip_create_body_does_not_store_auth_pass(): + """POST/PUT /api/vip — `payload.auth_pass`, routers/vip.py:585,679.""" + rendered, _ = _capture({ + "name": "vip-prod", "virtual_ip": "10.20.30.40", "interface": "eth0", + "virtual_router_id": 51, "auth_pass": VRRP_SECRET, + }) + _assert_absent(rendered, VRRP_SECRET) + + +def test_keepalived_config_delivery_does_not_store_the_rendered_secret(): + """GET /api/agents/{n}/keepalived-config — `keepalived.config_content`. + + Polled on the SSL cadence, so an unmasked capture rewrites the secret to the + audit table roughly 576 times a day per member node. + """ + rendered, _ = _capture({ + "agent_name": "prod-lb-01", "status": "available", + "config_path": "/etc/keepalived/keepalived.conf", + "keepalived": { + "vip_id": 3, "vip_name": "vip-prod", + "config_content": KEEPALIVED_CONF, "config_hash": "abc123", + }, + }) + _assert_absent(rendered, VRRP_SECRET) + assert "auth_pass" in rendered, "the directive should stay visible, only its value masked" + assert "vrrp_instance VI_1" in rendered, "masking must not destroy the rest of the config" + + +def test_keepalived_discovery_body_does_not_store_the_found_secret(): + """POST /api/agents/{n}/keepalived-discovery — `config_content`. + + routers/agent.py already pops auth_pass out of the parsed analysis, + Fernet-encrypts it into its own column and stores only + `vip_discoveries.raw_config_masked`. Capturing the request that produced all + that, unmasked, would put the plaintext straight back next to it. + """ + rendered, _ = _capture({ + "agent_name": "prod-lb-01", "exists": True, "is_managed": False, + "config_path": "/etc/keepalived/keepalived.conf", + "config_content": KEEPALIVED_CONF, + }) + _assert_absent(rendered, VRRP_SECRET) + + +def test_auth_pass_is_masked_when_the_body_is_too_large_to_parse(): + """The truncated `_raw` path, where line breaks are the escape `\\n`. + + A value pattern that stops only at a REAL newline runs to the end of the + string here: no leak, but the whole remainder of the config is masked and + the row is useless. Both properties are asserted. + """ + payload = {"config_content": KEEPALIVED_CONF + "backend b\n server s1 10.0.0.1:80 check\n" * 400} + rendered, truncated = _capture(payload) + assert truncated, "this fixture must exercise the truncated path" + _assert_absent(rendered, VRRP_SECRET) + assert "server s1 10.0.0.1:80" in rendered, ( + "masking ran past the end of the auth_pass line and ate the rest of the config" + ) + + +# -------------------------------------------------------------------------- +# TOTP. routers/mfa.py logs `{"secret_len": ...}` with the comment +# "NEVER log the secret itself". +# -------------------------------------------------------------------------- + +def test_mfa_enroll_response_does_not_store_the_totp_secret_in_either_field(): + """POST /api/mfa/enroll — returns `secret` AND `otpauth_uri`. + + Redacting one while the same value sits in the other is not redaction. + """ + rendered, _ = _capture({ + "secret": TOTP_SECRET, + "otpauth_uri": f"otpauth://totp/OpenManager:admin?secret={TOTP_SECRET}&issuer=OpenManager", + "qr_size": 256, + }) + _assert_absent(rendered, TOTP_SECRET) + + +def test_userinfo_credentials_in_a_url_are_dropped(): + rendered, _ = _capture({"webhook": "https://svc:Sup3rSecret@hooks.example.com/notify?api_key=abc123"}) + _assert_absent(rendered, "Sup3rSecret", "abc123") + assert "hooks.example.com" in rendered, "the host is the diagnostic value; keep it" + + +# -------------------------------------------------------------------------- +# HAProxy config. We never RENDER credentials into one, but the agent uploads +# the node's real file and the operator can paste one. +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("cap,label", [(8192, "truncated _raw path"), (10 ** 6, "parsed path")]) +def test_uploaded_haproxy_config_masks_credentials_on_both_paths(cap, label): + """POST /api/configuration/agents/{n}/config-response, and + POST /api/config/validate.""" + rendered, _ = _capture({"config_content": HAPROXY_CFG, "config_path": "/etc/haproxy/haproxy.cfg"}, cap=cap) + _assert_absent(rendered, STATS_PASSWORD, USERLIST_HASH, "Hunter2Plain") + assert "stats auth admin:" in rendered, f"[{label}] the account name is diagnostic; keep it" + assert "server web1 10.0.0.1:80" in rendered, f"[{label}] the rest of the config must survive" + + +@pytest.mark.parametrize("prose", [ + "invalid password format", + "the password must be at least 8 characters", + "authentication failed for user admin", +]) +def test_ordinary_prose_is_not_mangled(prose): + """Over-matching would blank the messages the log exists to show.""" + rendered, _ = _capture({"detail": prose}) + assert prose in rendered, f"redaction damaged an ordinary message: {rendered}" + + +# -------------------------------------------------------------------------- +# Regressions guarding what already worked, so a later rule change cannot +# quietly trade one of these away for one of the above. +# -------------------------------------------------------------------------- + +def test_login_exchange_stores_neither_the_password_nor_the_token(): + req, _ = _capture({"username": "admin", "password": "hunter2hunter2"}) + _assert_absent(req, "hunter2hunter2") + res, _ = _capture({"access_token": JWT, "token_type": "bearer", "user": {"id": 1}}) + _assert_absent(res, JWT) + + +def test_private_key_is_redacted_even_under_an_innocent_key_name(): + """The value-shape guard is the net under the key-name rules.""" + rendered, _ = _capture({"blob": PEM_KEY, "note": "backup"}) + _assert_absent(rendered, "MIIEowIBAAKCAQEA") + + +def test_dns_provider_credentials_are_redacted(): + cf, _ = _capture({"provider": "cloudflare", "api_token": "cf_live_abcdefghijklmnop", "zone_id": "z1"}) + _assert_absent(cf, "cf_live_abcdefghijklmnop") + gd, _ = _capture({"provider": "godaddy", "api_key": "gd_key_1234567890", "api_secret": "gd_secret_098"}) + _assert_absent(gd, "gd_key_1234567890", "gd_secret_098") + + +def test_innocent_urls_survive_untouched(): + """Scrubbing must not rewrite the ACME URLs an operator reads back.""" + for url in ( + "https://acme-v02.api.letsencrypt.org/directory", + "https://acme-v02.api.letsencrypt.org/acme/acct/12345", + ): + rendered, _ = _capture({"directory_url": url}) + assert url in rendered, f"an innocent URL was rewritten: {rendered}" + + +def test_credential_headers_are_presence_only_and_the_rest_are_dropped(): + out = redact_headers({ + "authorization": f"Bearer {JWT}", + "x-api-key": "agt_" + "a" * 32, + "cookie": "session=abc123", + "user-agent": "curl/8.4.0", + "x-forwarded-for": "10.20.30.5", + "x-internal-secret": "not-on-the-allowlist", + }) + rendered = json.dumps(out) + _assert_absent(rendered, JWT, "agt_" + "a" * 32, "abc123", "not-on-the-allowlist") + assert out["user-agent"] == "curl/8.4.0" + assert out["x-forwarded-for"] == "10.20.30.5" + assert "x-internal-secret" not in out, "an unlisted header must be dropped, not kept" + + +@pytest.mark.parametrize("key", ["auth_pass", "authPass", "auth-pass", "AUTH_PASS"]) +def test_auth_pass_key_matches_in_every_spelling(key): + assert is_secret_key(key), ( + f"{key!r} normalizes to something no rule matches. 'password' is not a " + f"substring of 'authpass' and the bare 'auth' entry is an exact match." + ) + + +@pytest.mark.parametrize("key", [ + "monkey", "key_suffix", "payload_size", "nonce_count", "keyboard_layout", + "config_path", "authenticated", "author", +]) +def test_innocent_field_names_are_still_kept(key): + """The other half of the trade: over-redaction blanks the fields the + feature exists to show.""" + assert not is_secret_key(key)