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.
This commit is contained in:
taylanbakircioglu
2026-08-15 11:04:31 +03:00
parent bd4a50943f
commit c5fbbd753f
2 changed files with 587 additions and 0 deletions
@@ -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:<task name>` 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"
)
@@ -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)