"""Unit tests for the customization manifest (rcust P5; spec §8 R4 mitigation). The manifest is PURE PRESENTATION (must never influence a verdict); these tests pin that it is COMPLETE (every customization surface a synthetic recipe exercises shows up), DETERMINISTIC (same inputs -> byte-identical JSON), serializable, and HC2-honoring (unapproved repo-local contributions are invisible). Pure / tmp-file only. Run cold: cc-ci-run -m pytest tests/unit/test_manifest.py -q """ from __future__ import annotations import json import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import discovery, manifest # noqa: E402 from harness import meta as meta_mod # noqa: E402 RECIPE = "ccci-manifest-fixture" def _mk_synthetic(tmp_path, monkeypatch, approved=True): """A synthetic recipe dir exercising EVERY manifest surface, plus a repo-local tests dir. cc-ci side: meta (2 data keys + 1 hook key non-default), ops.py (2 pre-ops), install_steps.sh, compose.ccci.yml, test_backup.py overlay, 2 functional + 1 playwright custom tests. repo-local side: test_restore.py overlay + 1 functional custom test (visible iff approved, HC2). """ ccci_root = tmp_path / "cc-ci-tests" d = ccci_root / RECIPE (d / "functional").mkdir(parents=True) (d / "playwright").mkdir() (d / "recipe_meta.py").write_text( "HTTP_TIMEOUT = 600\n" "DEPS = ['keycloak']\n" "def EXTRA_ENV(ctx):\n return {}\n" "_PRIVATE = 'exempt'\n" ) (d / "ops.py").write_text("def pre_upgrade(ctx):\n pass\n\ndef pre_backup(ctx):\n pass\n") (d / "install_steps.sh").write_text("#!/usr/bin/env bash\n") (d / "compose.ccci.yml").write_text("version: '3.8'\n") (d / "test_backup.py").write_text("# lifecycle overlay\n") (d / "functional" / "test_a.py").write_text("# custom\n") (d / "functional" / "test_b.py").write_text("# custom\n") (d / "playwright" / "test_ui.py").write_text("# custom\n") rl = tmp_path / "repo-local" (rl / "functional").mkdir(parents=True) (rl / "functional" / "test_c.py").write_text("# repo-local custom\n") (rl / "test_restore.py").write_text("# repo-local lifecycle overlay\n") monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(ccci_root / r)) monkeypatch.setattr(meta_mod, "TESTS_DIR", str(ccci_root)) # compose.ccci.yml discovery approved_file = tmp_path / "approved.txt" approved_file.write_text(f"{RECIPE}\n" if approved else "") monkeypatch.setenv("CCCI_REPO_LOCAL_APPROVED_FILE", str(approved_file)) meta = meta_mod.load(RECIPE, tests_dir=str(ccci_root)) return meta, str(rl) def test_manifest_complete(tmp_path, monkeypatch): # Every surface the synthetic recipe customizes appears — nothing silently dropped (R4). meta, rl = _mk_synthetic(tmp_path, monkeypatch) m = manifest.build(RECIPE, meta, rl) assert m["meta_non_default"] == { "DEPS": ["keycloak"], "EXTRA_ENV": "", "HTTP_TIMEOUT": 600, } assert m["hooks"] == { "ops.py": {"cc-ci": ["pre_backup", "pre_upgrade"]}, "install_steps.sh": "cc-ci", "compose.ccci.yml": "cc-ci", } assert m["overlays"] == {"backup": "cc-ci", "restore": "repo-local"} assert m["custom_tests"] == { "cc-ci": {"functional": 2, "playwright": 1}, "repo-local": {"functional": 1}, } assert m["env_overrides"] == [] def test_manifest_deterministic_and_serializable(tmp_path, monkeypatch): meta, rl = _mk_synthetic(tmp_path, monkeypatch) a = manifest.build(RECIPE, meta, rl) b = manifest.build(RECIPE, meta, rl) assert json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True) assert json.loads(json.dumps(a)) == a # round-trips: no callables/tuples leak through def test_manifest_zero_config_floor(tmp_path, monkeypatch): # A recipe with NO customization at all -> every section empty, render says so explicitly. ccci_root = tmp_path / "cc-ci-tests" (ccci_root / RECIPE).mkdir(parents=True) monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(ccci_root / r)) monkeypatch.setattr(meta_mod, "TESTS_DIR", str(ccci_root)) monkeypatch.setenv("CCCI_REPO_LOCAL_APPROVED_FILE", str(tmp_path / "missing.txt")) meta = meta_mod.load(RECIPE, tests_dir=str(ccci_root)) m = manifest.build(RECIPE, meta, None) assert m == { "meta_non_default": {}, "hooks": {}, "overlays": {}, "custom_tests": {}, "env_overrides": [], } out = manifest.render(RECIPE, m) assert f"===== customization manifest: {RECIPE} =====" in out assert "(none — zero-config floor)" in out def test_manifest_repo_local_hc2_gate(tmp_path, monkeypatch): # Unapproved recipe -> repo-local overlay + custom tests INVISIBLE (same default-deny as the # discovery they ride on; the manifest must not advertise code the run will not execute). meta, rl = _mk_synthetic(tmp_path, monkeypatch, approved=False) m = manifest.build(RECIPE, meta, rl) assert m["overlays"] == {"backup": "cc-ci"} # repo-local test_restore.py gone assert "repo-local" not in m["custom_tests"] def test_manifest_env_overrides_and_ci_flag(tmp_path, monkeypatch): meta, rl = _mk_synthetic(tmp_path, monkeypatch) monkeypatch.setenv("CCCI_SKIP_GENERIC_BACKUP", "1") monkeypatch.setenv("CCCI_SKIP_GENERIC_UPGRADE", "0") # falsy -> not an active override m = manifest.build(RECIPE, meta, rl) assert m["env_overrides"] == ["CCCI_SKIP_GENERIC_BACKUP"] monkeypatch.delenv("DRONE", raising=False) assert "!!" not in manifest.render(RECIPE, m) # local dev: no CI warning monkeypatch.setenv("DRONE", "true") # riding a CI run -> loud flag (P2c) assert "!! dev-only override active in CI" in manifest.render(RECIPE, m) def test_manifest_redacts_sensitive_named_values(tmp_path, monkeypatch): # Meta values are repo-public by construction, but the manifest lands on the dashboard: # secret-NAMED entries (top-level or nested dict keys, e.g. plausible's # EXTRA_ENV["SECRET_KEY_BASE"] dummy) render as '' — name shown, value masked. # Non-sensitive names (incl. KEYCLOAK_* — 'KEY' matches only as a word segment) pass through. ccci_root = tmp_path / "cc-ci-tests" d = ccci_root / RECIPE d.mkdir(parents=True) (d / "recipe_meta.py").write_text( "EXTRA_ENV = {\n" " 'SECRET_KEY_BASE': 'dummy-ci-constant',\n" " 'API_KEY': 'also-dummy',\n" " 'KEYCLOAK_URL': 'https://kc.example',\n" "}\n" ) monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(ccci_root / r)) monkeypatch.setattr(meta_mod, "TESTS_DIR", str(ccci_root)) monkeypatch.setenv("CCCI_REPO_LOCAL_APPROVED_FILE", str(tmp_path / "missing.txt")) meta = meta_mod.load(RECIPE, tests_dir=str(ccci_root)) m = manifest.build(RECIPE, meta, None) assert m["meta_non_default"]["EXTRA_ENV"] == { "SECRET_KEY_BASE": "", "API_KEY": "", "KEYCLOAK_URL": "https://kc.example", } out = manifest.render(RECIPE, m) assert "dummy-ci-constant" not in out and "also-dummy" not in out assert "SECRET_KEY_BASE" in out # the key NAME stays visible def test_render_lists_every_surface(tmp_path, monkeypatch): meta, rl = _mk_synthetic(tmp_path, monkeypatch) out = manifest.render(RECIPE, manifest.build(RECIPE, meta, rl)) lines = out.splitlines() assert lines[0] == f"===== customization manifest: {RECIPE} =====" assert "meta (non-default): DEPS=['keycloak'] EXTRA_ENV='' HTTP_TIMEOUT=600" in lines assert ( "hooks: ops.py[pre_backup,pre_upgrade](cc-ci) install_steps.sh(cc-ci) compose.ccci.yml(cc-ci)" in lines ) assert "overlays: test_backup.py(cc-ci) test_restore.py(repo-local)" in lines assert "custom tests: functional/=2 playwright/=1 (cc-ci) functional/=1 (repo-local)" in lines assert "env overrides: (none)" in lines