feat(1e): HC2 repo-local approval allowlist (default-deny) + discovery gate

- tests/repo-local-approved.txt (empty ⇒ default-deny); CCCI_REPO_LOCAL_APPROVED_FILE override.
- discovery: repo_local_approved()/_gated() centralize the gate; resolve_overlay_op + generic_op
  (HC3 additive split); custom_tests/install_steps/pre_op_hook all honor the gate.
- unit tests rewritten for approved-vs-not + the generic floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 02:55:58 +01:00
parent 0226167b49
commit d38a695fa3
3 changed files with 207 additions and 47 deletions

View File

@ -1,9 +1,10 @@
"""Unit tests for overlay/custom/hook discovery + precedence (Phase 1d, DG4).
"""Unit tests for overlay/custom/hook discovery + precedence (Phase 1d DG4 + Phase 1e HC2/HC3).
Deterministic, no deployment — proves the resolution rule (repo-local > cc-ci > generic) and the
invariant "no overlay for an op ⇒ generic runs". Run with: `cc-ci-run -m pytest tests/unit`.
These live under tests/unit/ (NOT a recipe name, NOT tests/_generic/) so the run orchestrator never
picks them up as overlays/custom tests."""
Deterministic, no deployment. Proves:
- the overlay resolution rule (repo-local > cc-ci) + the generic floor (HC3: additive, separate);
- the HC2 repo-local approval gate (default-deny; repo-local consulted only for approved recipes).
Run with: `cc-ci-run -m pytest tests/unit`. These live under tests/unit/ (NOT a recipe name, NOT
tests/_generic/) so the run orchestrator never picks them up as overlays/custom tests."""
import os
import sys
@ -12,42 +13,105 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")
from harness import discovery # noqa: E402
def test_no_overlay_falls_back_to_generic():
# hedgedoc has no tests/hedgedoc/ in cc-ci and no repo-local dir -> generic is the floor (DG4 invariant)
source, path = discovery.resolve_op("hedgedoc", "install", None)
assert source == "generic"
def _approve(tmp_path, *recipes):
"""Point the allowlist at a temp file approving the given recipes (HC2), and return a cleanup."""
f = tmp_path / "approved.txt"
f.write_text("".join(f"{r}\n" for r in recipes))
os.environ["CCCI_REPO_LOCAL_APPROVED_FILE"] = str(f)
def teardown_function():
os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
# ---- HC3: generic is the floor; overlay resolution is separate + additive --------------------
def test_no_overlay_means_generic_floor():
# hedgedoc ships no tests/hedgedoc/ overlay and no repo-local -> no overlay; generic floor exists
assert discovery.resolve_overlay_op("hedgedoc", "install", None) is None
src, path = discovery.generic_op("install")
assert src == "generic"
assert path == os.path.join(discovery.GENERIC_DIR, "test_install.py")
# back-compat resolver still yields generic when there is no overlay
assert discovery.resolve_op("hedgedoc", "install", None) == ("generic", path)
def test_cc_ci_overlay_overrides_generic():
# custom-html ships cc-ci overlays for all four ops -> cc-ci wins over generic when no repo-local
def test_cc_ci_overlay_found_for_each_op():
# custom-html ships cc-ci overlays for all four ops -> resolve_overlay_op returns the cc-ci file
for op in discovery.LIFECYCLE_OPS:
source, path = discovery.resolve_op("custom-html", op, None)
assert source == "cc-ci", op
assert path == os.path.join(discovery.cc_ci_dir("custom-html"), f"test_{op}.py")
res = discovery.resolve_overlay_op("custom-html", op, None)
assert res == ("cc-ci", os.path.join(discovery.cc_ci_dir("custom-html"), f"test_{op}.py")), op
def test_repo_local_wins_same_name_collision(tmp_path):
# repo-local is upstream-authoritative: a repo-local test_install.py beats cc-ci's for that op
# ---- HC2: repo-local approval gate (default-deny) --------------------------------------------
def test_repo_local_ignored_when_not_approved(tmp_path):
# default-deny: a repo-local overlay is NOT consulted for an unapproved recipe -> cc-ci wins
_approve(tmp_path) # empty allowlist
(tmp_path / "test_install.py").write_text("# repo-local overlay\n")
source, path = discovery.resolve_op("custom-html", "install", str(tmp_path))
assert source == "repo-local"
assert path == str(tmp_path / "test_install.py")
res = discovery.resolve_overlay_op("custom-html", "install", str(tmp_path))
assert res == ("cc-ci", os.path.join(discovery.cc_ci_dir("custom-html"), "test_install.py"))
assert discovery.repo_local_approved("custom-html") is False
def test_custom_tests_additive_from_both_locations(tmp_path):
# non-lifecycle test_*.py are opt-in and additive; lifecycle names are excluded
(tmp_path / "test_sso.py").write_text("# repo-local custom\n")
(tmp_path / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
customs = discovery.custom_tests("custom-html", str(tmp_path))
def test_repo_local_wins_when_approved(tmp_path):
# approved: repo-local is upstream-authoritative and beats cc-ci for the same op
rl = tmp_path / "repo"
rl.mkdir()
(rl / "test_install.py").write_text("# repo-local overlay\n")
_approve(tmp_path, "custom-html")
assert discovery.repo_local_approved("custom-html") is True
res = discovery.resolve_overlay_op("custom-html", "install", str(rl))
assert res == ("repo-local", str(rl / "test_install.py"))
def test_custom_tests_repo_local_gated(tmp_path):
# non-lifecycle test_*.py from repo-local only count for approved recipes; lifecycle names excluded
rl = tmp_path / "repo"
rl.mkdir()
(rl / "test_sso.py").write_text("# repo-local custom\n")
(rl / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
_approve(tmp_path) # not approved -> repo-local custom ignored
assert discovery.custom_tests("custom-html", str(rl)) == []
_approve(tmp_path, "custom-html") # approved -> repo-local custom honored
customs = discovery.custom_tests("custom-html", str(rl))
names = {(src, os.path.basename(p)) for src, p in customs}
assert ("repo-local", "test_sso.py") in names
assert all(os.path.basename(p) != "test_install.py" for _, p in customs)
def test_install_steps_repo_local_over_cc_ci(tmp_path):
(tmp_path / "install_steps.sh").write_text("#!/usr/bin/env bash\n")
hook = discovery.install_steps("custom-html", str(tmp_path))
assert hook is not None
assert hook[0] == "repo-local"
def test_install_steps_repo_local_gated(tmp_path):
rl = tmp_path / "repo"
rl.mkdir()
(rl / "install_steps.sh").write_text("#!/usr/bin/env bash\n")
_approve(tmp_path) # not approved -> repo-local hook ignored (custom-html has no cc-ci hook)
assert discovery.install_steps("custom-html", str(rl)) is None
_approve(tmp_path, "custom-html") # approved -> repo-local hook honored
hook = discovery.install_steps("custom-html", str(rl))
assert hook == ("repo-local", str(rl / "install_steps.sh"))
assert discovery.install_steps("hedgedoc", None) is None
def test_pre_op_hook_repo_local_gated(tmp_path):
rl = tmp_path / "repo"
rl.mkdir()
(rl / "ops.py").write_text("def pre_upgrade(domain, meta):\n pass\n")
_approve(tmp_path) # not approved -> repo-local ops.py ignored
assert discovery.pre_op_hook("custom-html", "upgrade", str(rl)) is None
_approve(tmp_path, "custom-html") # approved -> repo-local pre-op hook honored
hook = discovery.pre_op_hook("custom-html", "upgrade", str(rl))
assert hook == ("repo-local", str(rl / "ops.py"))
# an ops.py that does NOT define pre_<op> is not a hook for that op
assert discovery.pre_op_hook("custom-html", "backup", str(rl)) is None
def test_default_allowlist_is_empty():
# the checked-in tests/repo-local-approved.txt approves nothing (default-deny invariant)
os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
assert discovery.approved_recipes() == set()