"""ORCH-101 (TC-09, AC-5 / NFR-3): scripts/gen_secrets.py — выпуск нового комплекта секретов. Контракт D8: криптослучайные webhook-секреты (>= 32 байт энтропии, hex); повторный запуск даёт другие значения; существующий .env никогда не перезаписывается молча (отказ exit=2, перезапись только --force); состав ключей вывода согласован с .env.example. """ import importlib.util import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts/gen_secrets.py" ENV_EXAMPLE = REPO_ROOT / ".env.example" _HEX64 = re.compile(r"^[0-9a-f]{64}$") def _load_module(): spec = importlib.util.spec_from_file_location("gen_secrets", SCRIPT) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _values(fragment: str) -> dict[str, str]: out = {} for line in fragment.splitlines(): if "=" in line and not line.startswith("#"): k, v = line.split("=", 1) out[k] = v return out def test_secret_is_cryptorandom_64_hex(): mod = _load_module() assert mod.TOKEN_BYTES >= 32 # AC-5: >= 32 байта энтропии value = mod.generate_secret() assert _HEX64.match(value), value def test_two_runs_give_different_values(): mod = _load_module() a, b = _values(mod.build_fragment()), _values(mod.build_fragment()) for key in mod.GENERATED_KEYS: assert _HEX64.match(a[key]) and _HEX64.match(b[key]) assert a[key] != b[key], f"{key}: повторный запуск дал то же значение" def test_external_tokens_are_placeholders(): mod = _load_module() vals = _values(mod.build_fragment()) for key in mod.EXTERNAL_KEYS: assert vals[key] == "", f"{key} должен быть пустым плейсхолдером" def test_output_keys_consistent_with_env_example(): """AC-5: каждое имя ключа из вывода генератора существует в .env.example.""" mod = _load_module() example = ENV_EXAMPLE.read_text(encoding="utf-8") example_keys = { line.split("=", 1)[0].strip() for line in example.splitlines() if "=" in line and not line.strip().startswith("#") } for key in (*mod.GENERATED_KEYS, *mod.EXTERNAL_KEYS): assert key in example_keys, f"{key} отсутствует в .env.example" def test_default_mode_prints_and_touches_no_files(tmp_path, capsys, monkeypatch): mod = _load_module() monkeypatch.chdir(tmp_path) rc = mod.main([]) assert rc == 0 out = capsys.readouterr().out assert "ORCH_PLANE_WEBHOOK_SECRET=" in out assert list(tmp_path.iterdir()) == [] # filesystem untouched def test_write_refuses_existing_file_without_force(tmp_path): mod = _load_module() target = tmp_path / ".env" target.write_text("KEEP=1\n", encoding="utf-8") rc = mod.main(["--write", str(target)]) assert rc == 2 # отказ, не перезапись assert target.read_text(encoding="utf-8") == "KEEP=1\n" # содержимое цело def test_write_creates_new_file_and_force_overwrites(tmp_path): mod = _load_module() target = tmp_path / ".env" assert mod.main(["--write", str(target)]) == 0 first = _values(target.read_text(encoding="utf-8")) assert _HEX64.match(first["ORCH_GITEA_WEBHOOK_SECRET"]) # --force перезаписывает, и секреты другие (не детерминированы). assert mod.main(["--write", str(target), "--force"]) == 0 second = _values(target.read_text(encoding="utf-8")) assert first["ORCH_GITEA_WEBHOOK_SECRET"] != second["ORCH_GITEA_WEBHOOK_SECRET"] def test_no_real_secret_committed_anywhere_near(): """Генератор не несёт зашитых значений: единственный источник — CSPRNG.""" text = SCRIPT.read_text(encoding="utf-8") assert not re.search(r"[0-9a-f]{32,}", text), "зашитое hex-значение в генераторе"