diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5bf4bed..145c238 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -60,8 +60,11 @@ These functions are provided for documentation examples and return mock data: - `process_tenant_request(tenant_id, request)` - Returns tenant result #### Configuration -- `secret_key` - Test encryption key (value: `"a" * 64`) -- `CACHEKIT_MASTER_KEY` - Environment variable set to `secret_key` (enables `@cache.secure` examples) +- `secret_key` - Test encryption key (value: `"a" * 64`). `@cache.secure` examples must + pass it explicitly (`master_key=secret_key`); the conftest does **not** set + `CACHEKIT_MASTER_KEY` in the environment because an ambient key turns encryption on + globally and breaks plain `@cache` fences that use `serializer="auto"` or custom + serializers. ## Skipping Examples with `notest` diff --git a/docs/conftest.py b/docs/conftest.py index e47447e..4318807 100644 --- a/docs/conftest.py +++ b/docs/conftest.py @@ -6,7 +6,6 @@ """ import logging -import os import time try: @@ -91,10 +90,16 @@ def expensive_computation(): logger = logging.getLogger("cachekit.examples") logger.setLevel(logging.INFO) - # Secret key for encryption examples (test value only) + # Secret key for encryption examples (test value only). Exposed as a global + # so @cache.secure fences can pass it explicitly (master_key=secret_key). + # + # We deliberately DO NOT set CACHEKIT_MASTER_KEY in the environment here. + # Since the PR #127 auto-detect + PR #200 settings re-read, an ambient + # master key turns encryption on globally, and the v0.6.0 cross-SDK rule + # then rejects any plain `@cache` fence using a non-cross-SDK serializer + # (serializer="auto", custom serializer instances) at decoration time. + # tests/conftest.py hit the same trap and fixed it the same way. See #205. secret_key = "a" * 64 # 32 bytes in hex - # Set env var so @cache.secure validation passes - os.environ["CACHEKIT_MASTER_KEY"] = secret_key globals_dict = { "cache": cache, diff --git a/tests/unit/test_docs_conftest_no_key_leak.py b/tests/unit/test_docs_conftest_no_key_leak.py new file mode 100644 index 0000000..f514333 --- /dev/null +++ b/tests/unit/test_docs_conftest_no_key_leak.py @@ -0,0 +1,48 @@ +"""Regression guard for issue #205. + +The markdown-docs suite runs only in the post-merge CI job (push to main), not +on PRs, so a regression here would turn `main` red instead of failing review. +This fast unit test runs on every PR and fails loudly if `docs/conftest.py` +starts setting CACHEKIT_MASTER_KEY in the process environment again. + +Why it matters: an ambient master key turns encryption on globally (PR #127 +auto-detect + PR #200 settings re-read), and the v0.6.0 cross-SDK rule then +rejects every plain `@cache` doc fence that uses a non-cross-SDK serializer +(serializer="auto", custom serializer instances) at decoration time. +""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path + +import pytest + +DOCS_CONFTEST = Path(__file__).resolve().parents[2] / "docs" / "conftest.py" + + +def _load_docs_conftest(): + spec = importlib.util.spec_from_file_location("docs_conftest_under_test", DOCS_CONFTEST) + assert spec and spec.loader, f"could not load {DOCS_CONFTEST}" + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.unit +def test_docs_globals_hook_does_not_set_master_key_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Invoking the markdown-docs globals hook must not leak CACHEKIT_MASTER_KEY.""" + monkeypatch.delenv("CACHEKIT_MASTER_KEY", raising=False) + + module = _load_docs_conftest() + globals_dict = module.pytest_markdown_docs_globals() + + assert "CACHEKIT_MASTER_KEY" not in os.environ, ( + "docs/conftest.py set CACHEKIT_MASTER_KEY in the environment. This turns " + "encryption on globally and breaks plain @cache fences using serializer='auto' " + "or custom serializers. Pass the key explicitly (master_key=secret_key) in the " + "@cache.secure fences instead. See issue #205." + ) + # The key must still be available to fences that opt in explicitly. + assert globals_dict.get("secret_key") == "a" * 64