"""ORCH-104 (TC-01…TC-21, AC-1…AC-11): структурные и unit-проверки `scripts/install_lite.py` — интерактивного установщика Lite-тиража. Зеркало паттернов `tests/test_bootstrap_script.py`: модуль грузится по файлу (import без side-effects, `main` только под `__main__`); side-effects (subprocess/HTTP/getpass/input) инъектируются фейками через monkeypatch module-level обёрток; чистые функции (`parse_env`/`render_env`/ `preflight_verdict`/`install_command`/`rank_candidates`/`select_candidate`) тестируются изолированно — без сети/docker/LLM (NFR-7). Структурные анти-дрейф (AC-9/AC-10/AC-7): stdlib-only AST-скан, нет `from src`, нет delete/force-операций, нет собственного канона статусов Plane, нет собственной генерации секретов (делегирование `gen_secrets.py`), нет хост-литералов (импорт `FORBIDDEN` из `test_no_host_hardcodes.py` — один источник истины), exit-контракт `{0,1,2}`, установщик не трогает рантайм (`STAGE_TRANSITIONS`/`QG_CHECKS`). """ import ast import importlib.util import os import sys from pathlib import Path # Один источник истины запрещённых боевых литералов (ORCH-101 AC-7, не копия). from tests.test_no_host_hardcodes import FORBIDDEN, find_violations REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts/install_lite.py" # Запрещённые delete/force-операции (INV-3: установщик ничего не удаляет/форсит). FORBIDDEN_DELETE_NEEDLES = ( "volume rm", "rm -rf", "down -v", "compose down", "rmtree", "os.remove", ".unlink", "push --force", "force-push", "force-with-lease", "branch -D", "branch -d", ) # Маркеры собственного канона статусов Plane (запрещены: канон — onboard-кирпич). FORBIDDEN_STATUS_NEEDLES = ( "Backlog", "To Analyse", "Code-Review", "Awaiting Deploy", "Monitoring after Deploy", ) # Запрет собственной генерации секретов (делегирование gen_secrets.py, AC-7). FORBIDDEN_SECRET_GEN_NEEDLES = ("token_hex", "token_urlsafe", "token_bytes") # stdlib-allowlist top-level импортов (INV-7: python stdlib-only). STDLIB_ALLOWED = { "argparse", "dataclasses", "getpass", "json", "os", "pathlib", "re", "secrets", "shutil", "socket", "subprocess", "sys", "tempfile", "time", "urllib", "uuid", } def _source() -> str: assert SCRIPT.is_file(), "scripts/install_lite.py отсутствует (FR-1)" return SCRIPT.read_text(encoding="utf-8") def _load_module(): spec = importlib.util.spec_from_file_location("install_lite", SCRIPT) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod # =========================================================================== # TC-18/TC-19/TC-20/TC-21 — структурные анти-дрейф (AC-7/AC-9/AC-10/INV). # =========================================================================== def test_tc18_imports_are_stdlib_only(): tree = ast.parse(_source()) offenders = [] for node in ast.walk(tree): if isinstance(node, ast.Import): offenders.extend(a.name.split(".")[0] for a in node.names if a.name.split(".")[0] not in STDLIB_ALLOWED) elif isinstance(node, ast.ImportFrom) and node.module: top = node.module.split(".")[0] if top not in STDLIB_ALLOWED: offenders.append(top) assert not offenders, f"не-stdlib импорты (INV-7): {sorted(set(offenders))}" def test_tc18_does_not_import_platform_modules(): src = _source() assert "from src" not in src and "import src" not in src, ( "установщик обязан быть stdlib-only без импортов платформы (INV-7/AC-10)" ) def test_tc19_has_no_delete_or_force_operations(): src = _source() offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src] assert not offenders, ( f"delete/force-операции запрещены (INV-3, AC-10): {offenders}" ) def test_tc19_carries_no_host_hardcodes(): """FORBIDDEN host-литералы (импорт find_violations — один источник истины).""" violations = find_violations(_source(), FORBIDDEN) assert not violations, ( f"хост-литералы в install_lite.py (AC-10): {violations}" ) def test_tc20_references_canonical_bricks(): src = _source() assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py (AC-7)" assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py (AC-7)" def test_tc20_carries_no_own_secret_generation(): """AC-7: собственной генерации секретов нет — только делегирование кирпичу.""" src = _source() offenders = [n for n in FORBIDDEN_SECRET_GEN_NEEDLES if n in src] assert not offenders, ( f"собственная генерация секретов запрещена (анти-форк AC-7): {offenders}; " "webhook-секреты — только gen_secrets.py" ) def test_tc20_carries_no_own_status_canon(): src = _source() offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src] assert not offenders, ( f"установщик несёт собственный канон статусов Plane (дрейф AC-7): " f"{offenders}; 22 статуса — только onboard_project.py/plane_sync" ) def test_tc21_does_not_touch_runtime_registries(): """AC-9: установщик вне рантайма — не ссылается на STAGE_TRANSITIONS/QG_CHECKS и не правит src/** (он — scripts-артефакт).""" src = _source() for needle in ("STAGE_TRANSITIONS", "QG_CHECKS", "check_ci_green"): assert needle not in src, ( f"установщик не должен ссылаться на рантайм-реестр {needle} (AC-9)" ) def test_tc18_module_import_has_no_side_effects(): before = dict(sys.modules) mod1 = _load_module() mod2 = _load_module() assert mod1.build_plan() == mod2.build_plan() assert before is not None # загрузка по файлу не должна падать # =========================================================================== # TC-01/TC-02 — entry-point, режимы, план. # =========================================================================== def test_tc01_exit_code_contract(): mod = _load_module() assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1) def test_tc01_plan_is_default_mode_and_modes_are_closed(): mod = _load_module() parser = mod.build_arg_parser() assert parser.parse_args([]).mode == "plan" # дефолт — ноль мутаций assert parser.parse_args(["apply"]).mode == "apply" assert parser.parse_args(["verify"]).mode == "verify" assert parser.parse_args([]).force is False assert parser.parse_args([]).non_interactive is False def test_tc01_plan_makes_zero_mutations(tmp_path, monkeypatch, capsys): """plan печатает план + preflight и НЕ пишет .env / не поднимает compose.""" mod = _load_module() env_path = tmp_path / ".env" monkeypatch.setattr(mod, "ROOT_ENV", str(env_path)) monkeypatch.setattr(mod, "WATCHDOG_ENV", str(tmp_path / ".env.watchdog")) # Чистый хост: ни одной мутации, ни одного docker up. monkeypatch.setattr(mod, "collect_facts", lambda env: { "docker": True, "compose": True, "git": True, "node": True, "claude": True, "prod_port": 8500, "prod_port_busy": False, "orch_already_up": False, "repos_owner_ok": True, }) def _boom_compose(*a, **k): raise AssertionError("plan не имеет права запускать docker compose") monkeypatch.setattr(mod, "_compose", _boom_compose) rc = mod.main(["plan"]) out = capsys.readouterr().out assert rc == mod.EXIT_OK assert not env_path.exists(), "plan создал .env — это мутация (AC-1 FAIL)" assert "план apply" in out and "preflight" in out def test_tc01_plan_returns_manual_on_blockers(tmp_path, monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) monkeypatch.setattr(mod, "collect_facts", lambda env: { "docker": False, "compose": False, "git": True, "node": True, "claude": True, "prod_port": 8500, "prod_port_busy": False, "orch_already_up": False, "repos_owner_ok": True, }) assert mod.main(["plan"]) == mod.EXIT_MANUAL def test_tc02_build_plan_is_ordered_and_complete(): mod = _load_module() names = [n for n, _ in mod.build_plan()] order = ("preflight", "deps", "discovery", "inputs", "secrets", "env", "up", "onboard", "health") assert names[0] == "preflight", "preflight — строго ДО любых мутаций (FR-2)" indexes = [names.index(n) for n in order] assert indexes == sorted(indexes), f"порядок шагов нарушен: {names}" assert len(names) >= 9 def test_tc02_apply_steps_match_normative_plan(): """Имена step-движка = нормативному плану (нет «теневых» шагов).""" mod = _load_module() assert [n for n, _ in mod.APPLY_STEPS] == [n for n, _ in mod.build_plan()] # =========================================================================== # TC-03 — preflight-вердикт (AC-2). # =========================================================================== def _clean_facts() -> dict: return { "docker": True, "compose": True, "git": True, "node": True, "claude": True, "prod_port": 8500, "prod_port_busy": False, "orch_already_up": False, "repos_owner_ok": True, } def test_tc03_preflight_clean_host_has_no_blockers(): mod = _load_module() blockers, warnings = mod.preflight_verdict(_clean_facts()) assert blockers == [] and warnings == [] def test_tc03_preflight_blocks_missing_deps_and_port_and_owner(): mod = _load_module() facts = _clean_facts() facts.update(docker=False, compose=False, node=False, prod_port_busy=True, orch_already_up=False, repos_owner_ok=False) blockers, _ = mod.preflight_verdict(facts) blob = "\n".join(blockers) assert "docker" in blob assert "node" in blob assert "8500" in blob assert "uid:gid" in blob def test_tc03_busy_port_owned_by_orchestrator_is_not_a_blocker(): mod = _load_module() facts = _clean_facts() facts.update(prod_port_busy=True, orch_already_up=True) blockers, _ = mod.preflight_verdict(facts) assert blockers == [], "уже поднятый орк на порту — не блокер (resume)" def test_tc03_missing_claude_is_warning_not_blocker(): mod = _load_module() facts = _clean_facts() facts.update(claude=False) blockers, warnings = mod.preflight_verdict(facts) assert blockers == [] assert any("LLM" in w or "claude" in w for w in warnings) # =========================================================================== # TC-04 — distro → команда установки (AC-3 / D-1). # =========================================================================== def test_tc04_install_command_per_distro(): mod = _load_module() apt_cmd = mod.install_command("ubuntu", "docker") dnf_cmd = mod.install_command("fedora", "docker") assert apt_cmd and "apt" in apt_cmd assert dnf_cmd and "dnf" in dnf_cmd assert apt_cmd != dnf_cmd # debian-семейство по ID_LIKE-подобному токену тоже резолвится: assert mod.install_command("debian", "git").startswith("sudo apt") def test_tc04_unknown_distro_degrades_to_none(): mod = _load_module() assert mod.install_command("arch", "docker") is None assert mod.install_command("", "docker") is None assert mod.install_command("ubuntu", "no-such-dep") is None def test_tc04_install_commands_are_in_allowlist(): """Команда, которую установщик может выполнить, ∈ фиксированному allowlist.""" mod = _load_module() allowed = mod.allowed_install_commands() for distro in ("ubuntu", "fedora"): for dep in mod.INSTALLABLE_DEPS: cmd = mod.install_command(distro, dep) if cmd is not None: assert cmd in allowed # =========================================================================== # TC-05 — управляемая установка: отказ / нет TTY → exit 2, без мутации (AC-3). # =========================================================================== def test_tc05_deps_no_tty_does_not_install_and_stops(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_isatty", lambda: False) def _boom_run_shell(*a, **k): raise AssertionError("без TTY установщик не имеет права что-либо ставить") monkeypatch.setattr(mod, "_run_shell", _boom_run_shell) ctx = { "args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}, "facts": {"docker": False, "git": True, "node": True, "claude": True}, "distro": "ubuntu", } import pytest with pytest.raises(mod.ManualStop): mod.step_deps(ctx) def test_tc05_deps_consent_no_keeps_root_clean(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_isatty", lambda: True) monkeypatch.setattr(mod, "_ask", lambda prompt: "N") # явный отказ def _boom_run_shell(*a, **k): raise AssertionError("при отказе оператора установка не выполняется") monkeypatch.setattr(mod, "_run_shell", _boom_run_shell) ctx = { "args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}, "facts": {"docker": False, "git": True, "node": True, "claude": True}, "distro": "ubuntu", } import pytest with pytest.raises(mod.ManualStop): mod.step_deps(ctx) def test_tc05_deps_all_present_is_skipped(monkeypatch): mod = _load_module() ctx = { "args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}, "facts": {"docker": True, "git": True, "node": True, "claude": True}, "distro": "ubuntu", } assert mod.step_deps(ctx) == "skipped" # =========================================================================== # TC-06/TC-07/TC-08 — детект Plane/Gitea + выбор (AC-4 / D-2). # =========================================================================== def test_tc06_discovery_ranks_two_candidates(): mod = _load_module() # 2 слушающих plane-порта (80, 8080), оба живые → 2 ранжированных кандидата. cands = mod.detect_existing( "plane", docker_fn=lambda: [], ports_fn=lambda ports: [80, 8080], prober=lambda url: True, ) assert len(cands) == 2 assert all(c.confidence == 2 for c in cands) # дедуп по URL: повторяющийся порт не плодит дублей cands2 = mod.detect_existing( "gitea", docker_fn=lambda: ["gitea-server gitea/gitea:1.22"], ports_fn=lambda ports: [3000], prober=lambda url: True, ) assert len(cands2) == 1 def test_tc06_zero_candidates_is_empty_list(): mod = _load_module() cands = mod.detect_existing( "plane", docker_fn=lambda: [], ports_fn=lambda ports: [], prober=lambda url: False, ) assert cands == [] def test_tc06_rank_candidates_orders_alive_above_dead(): mod = _load_module() ranked = mod.rank_candidates([ ("http://127.0.0.1:80", "port", False), ("http://127.0.0.1:8080", "port", True), ]) assert [c.url for c in ranked] == [ "http://127.0.0.1:8080", "http://127.0.0.1:80"] def test_tc07_select_candidate_valid_and_out_of_range(): mod = _load_module() cands = mod.rank_candidates([("http://a", "port", True), ("http://b", "port", True)]) assert mod.select_candidate(cands, "1") in ("http://a", "http://b") assert mod.select_candidate(cands, "99") is None # вне диапазона → ручной assert mod.select_candidate(cands, "") is None # пусто → ручной assert mod.select_candidate(cands, "abc") is None # мусор → ручной (never-raise) def test_tc08_detect_never_raises_on_probe_failure(): mod = _load_module() def _raising_docker(): raise RuntimeError("docker недоступен") def _raising_ports(ports): raise OSError("socket error") # исключение в docker-ps → [] assert mod.detect_existing("plane", docker_fn=_raising_docker, ports_fn=lambda p: [80]) == [] # исключение в порт-скане → [] assert mod.detect_existing("gitea", docker_fn=lambda: [], ports_fn=_raising_ports) == [] # исключение в пробе живости конкретного URL → кандидат «мёртв», не падение cands = mod.detect_existing( "gitea", docker_fn=lambda: [], ports_fn=lambda p: [3000], prober=lambda url: (_ for _ in ()).throw(RuntimeError()), ) assert all(c.confidence == 1 for c in cands) # =========================================================================== # TC-09 — живая верификация ДО записи (AC-5). # =========================================================================== def test_tc09_verify_plane_rejects_401_accepts_200(): mod = _load_module() reject = mod.verify_plane_token( "http://plane", "ws", "bad", http=lambda u, h, t: (401, "")) accept = mod.verify_plane_token( "http://plane", "ws", "good", http=lambda u, h, t: (200, "[]")) assert reject[0] is False and accept[0] is True def test_tc09_verify_gitea_rejects_401_accepts_200(): mod = _load_module() assert mod.verify_gitea_token( "http://gitea", "bad", http=lambda u, h, t: (401, ""))[0] is False assert mod.verify_gitea_token( "http://gitea", "good", http=lambda u, h, t: (200, "{}"))[0] is True def test_tc09_verify_telegram_ok_false_is_rejected(): mod = _load_module() assert mod.verify_telegram_token( "bad", http=lambda u, h, t: (200, '{"ok": false}'))[0] is False assert mod.verify_telegram_token( "good", http=lambda u, h, t: (200, '{"ok": true}'))[0] is True assert mod.verify_telegram_token( "x", http=lambda u, h, t: (401, ""))[0] is False def test_tc09_collect_secret_does_not_write_invalid(monkeypatch): """Битый токен НЕ принимается; верный — принимается (verify-before-write).""" mod = _load_module() monkeypatch.setattr(mod, "_isatty", lambda: True) seq = iter(["bad-token", "good-token"]) monkeypatch.setattr(mod, "_getsecret", lambda prompt: next(seq)) ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}} def verify(value): return (value == "good-token", "401") got = mod._collect_secret(ctx, "ORCH_PLANE_API_TOKEN", "Plane", verify, "инструкция") assert got == "good-token" # принят только прошедший верификацию def test_tc09_collect_secret_no_tty_fails_closed(): mod = _load_module() ctx = {"args": mod.build_arg_parser().parse_args(["--non-interactive", "apply"]), "root_env": {}} import pytest with pytest.raises(mod.ManualStop): mod._collect_secret(ctx, "ORCH_GITEA_TOKEN", "Gitea", lambda v: (False, "x"), "инструкция") # =========================================================================== # TC-10/TC-11 — parse_env / render_env (AC-6/AC-7). # =========================================================================== def test_tc10_parse_env_roundtrip(): mod = _load_module() text = "# шапка\nA=1\nB=\n\n# хвост\nC = three \n" assert mod.parse_env(text) == {"A": "1", "B": "", "C": "three"} def test_tc11_render_env_preserves_canon_and_appends_unknown(): mod = _load_module() example = "# шапка\nA=1\nB=\n\n# хвост\n" rendered = mod.render_env(example, {"B": "v", "NEW": "n"}) assert "# шапка" in rendered and "A=1" in rendered # канон сохранён assert "B=v" in rendered # ключ канона получил значение assert "NEW=n" in rendered # внеканонный — управляемым блоком # идемпотентность: повторный рендер от результата стабилен по ключам again = mod.render_env(rendered, {"B": "v", "NEW": "n"}) assert mod.parse_env(again)["B"] == "v" assert mod.parse_env(again)["NEW"] == "n" def test_tc11_watchdog_keys_split_to_their_own_file(): mod = _load_module() root, watchdog = mod.split_watchdog_overrides( {"ORCH_GITEA_TOKEN": "x", "WATCHDOG_TG_BOT_TOKEN": "y"}) assert root == {"ORCH_GITEA_TOKEN": "x"} assert watchdog == {"WATCHDOG_TG_BOT_TOKEN": "y"} # =========================================================================== # TC-12/TC-13 — секрет-гигиена + no-silent-overwrite (AC-6/AC-7). # =========================================================================== def test_tc12_write_private_sets_600(tmp_path, monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) path = tmp_path / ".env" mod._write_private(str(path), "ORCH_GITEA_TOKEN=secretvalue\n") assert (path.stat().st_mode & 0o777) == 0o600 def test_tc12_mask_secret_never_reveals_value(): mod = _load_module() masked = mod.mask_secret("super-secret-token-value") assert "super-secret-token-value" not in masked assert mod.mask_secret("") == "<пусто>" def test_tc12_secrets_step_does_not_log_secret_value(tmp_path, monkeypatch, capsys): mod = _load_module() monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) secret = "deadbeefcafefacefeed0123456789abcdef0123456789abcdef0123456789ab" def _fake_gen(cmd, env=None, timeout=600): path = cmd[cmd.index("--write") + 1] with open(path, "w", encoding="utf-8") as f: f.write(f"ORCH_PLANE_WEBHOOK_SECRET={secret}\n" f"ORCH_GITEA_WEBHOOK_SECRET={secret}\n") return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(mod, "_run", _fake_gen) ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}} assert mod.step_secrets(ctx) == "ok" out = capsys.readouterr().out assert secret not in out, "значение секрета утекло в лог (NFR-2 FAIL)" assert ctx["overrides"]["ORCH_PLANE_WEBHOOK_SECRET"] == secret def test_tc13_secrets_no_overwrite_without_force(monkeypatch): mod = _load_module() def _boom(*a, **k): raise AssertionError("при наличии секретов gen_secrets.py не вызывается") monkeypatch.setattr(mod, "_run", _boom) ctx = { "args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {"ORCH_PLANE_WEBHOOK_SECRET": "a" * 64, "ORCH_GITEA_WEBHOOK_SECRET": "b" * 64}, "overrides": {}, } assert mod.step_secrets(ctx) == "skipped" # =========================================================================== # TC-14 — онбординг кирпичом (AC-7). # =========================================================================== def test_tc14_onboard_parses_registry_and_propagates_manual(tmp_path, monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) monkeypatch.setattr(mod, "ROOT_ENV_EXAMPLE", str(REPO_ROOT / ".env.example")) merged = '[{"repo":"acme","work_item_prefix":"ACME"}]' calls = {"recreate": 0} def _fake_run(cmd, env=None, timeout=600): joined = " ".join(cmd) if "onboard_project.py" in joined and "apply" in cmd: report = {"instructions": [f"ORCH_PROJECTS_JSON={merged}"], "exit_code": 2, "steps": []} return type("P", (), {"returncode": 2, "stdout": __import__("json").dumps(report), "stderr": ""})() if "onboard_project.py" in joined and "verify" in cmd: return type("P", (), {"returncode": 2, "stdout": "{}", "stderr": ""})() raise AssertionError(f"неожиданный вызов: {joined}") def _fake_compose(*a, **k): calls["recreate"] += 1 return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(mod, "_run", _fake_run) monkeypatch.setattr(mod, "_compose", _fake_compose) args = mod.build_arg_parser().parse_args([ "apply", "--name", "Acme", "--repo", "acme", "--prefix", "ACME", "--stack", "python", "--test-cmd", "pytest", "--prod-port", "8600", "--staging-port", "8601", "--webhook-url", "http://x/webhook/gitea"]) ctx = {"args": args, "root_env": {}, "overrides": {}} status = mod.step_onboard(ctx) written = (tmp_path / ".env").read_text(encoding="utf-8") assert merged in written, "merged ORCH_PROJECTS_JSON не записан в .env" assert ctx["onboard_manual"] is True assert status == "manual-step" assert calls["recreate"] == 1 def test_tc14_onboard_skips_when_project_present(): mod = _load_module() args = mod.build_arg_parser().parse_args([ "apply", "--name", "Acme", "--repo", "acme", "--prefix", "ACME"]) ctx = {"args": args, "root_env": {"ORCH_PROJECTS_JSON": '[{"repo":"acme"}]'}, "overrides": {}} assert mod.step_onboard(ctx) == "skipped" def test_tc14_onboard_without_params_is_manual_step(): mod = _load_module() args = mod.build_arg_parser().parse_args(["apply"]) ctx = {"args": args, "root_env": {}, "overrides": {}} assert mod.step_onboard(ctx) == "manual-step" assert ctx["onboard_manual"] is True # =========================================================================== # TC-15 — идемпотентность (AC-8). # =========================================================================== def test_tc15_secrets_then_env_are_idempotent(tmp_path, monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) monkeypatch.setattr(mod, "ROOT_ENV_EXAMPLE", str(REPO_ROOT / ".env.example")) monkeypatch.setattr(mod, "WATCHDOG_ENV", str(tmp_path / ".env.watchdog")) monkeypatch.setattr(mod, "WATCHDOG_ENV_EXAMPLE", str(REPO_ROOT / ".env.watchdog.example")) secret = "a" * 64 def _fake_gen(cmd, env=None, timeout=600): path = cmd[cmd.index("--write") + 1] with open(path, "w", encoding="utf-8") as f: f.write(f"ORCH_PLANE_WEBHOOK_SECRET={secret}\n" f"ORCH_GITEA_WEBHOOK_SECRET={secret}\n") return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(mod, "_run", _fake_gen) args = mod.build_arg_parser().parse_args(["apply"]) ctx = {"args": args, "root_env": {}, "overrides": {}} # первый прогон: секреты выпущены, env собран assert mod.step_secrets(ctx) == "ok" assert mod.step_env(ctx) == "ok" first = (tmp_path / ".env").read_text(encoding="utf-8") # второй прогон тех же шагов поверх собранного .env ctx2 = {"args": args, "root_env": mod.parse_env(first), "overrides": {}} assert mod.step_secrets(ctx2) == "skipped" # уже есть → не перевыпуск assert mod.step_env(ctx2) == "ok" second = (tmp_path / ".env").read_text(encoding="utf-8") assert mod.parse_env(second)["ORCH_PLANE_WEBHOOK_SECRET"] == secret assert mod.parse_env(first) == mod.parse_env(second) # значения стабильны # =========================================================================== # TC-16 — no-TTY manual_checkpoint fail-closed (AC-8/AC-11). # =========================================================================== def test_tc16_manual_checkpoint_no_tty_raises(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_isatty", lambda: False) def _boom_ask(prompt): raise AssertionError("без TTY input() вызываться не должен") monkeypatch.setattr(mod, "_ask", _boom_ask) import pytest with pytest.raises(mod.ManualStop): mod.manual_checkpoint("step", ["do x"], lambda: (True, "")) def test_tc16_manual_checkpoint_verifies_after_confirm(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_isatty", lambda: True) monkeypatch.setattr(mod, "_ask", lambda prompt: "") # верификация проходит со второй попытки seq = iter([(False, "ещё нет"), (True, "")]) mod.manual_checkpoint("step", ["do x"], lambda: next(seq), max_tries=3) # =========================================================================== # TC-17 — health-гейт (AC-11). # =========================================================================== def test_tc17_health_pass_on_200(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_http", lambda url, headers=None, timeout=10: (200, "{}")) ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}} assert mod.step_health(ctx) == "ok" def test_tc17_health_fail_raises_install_error(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "_http", lambda url, headers=None, timeout=10: (503, "")) monkeypatch.setattr(mod, "_compose", lambda *a, **k: type("P", (), {"stdout": "", "stderr": ""})()) monkeypatch.setattr(mod, "time", type("T", (), {"monotonic": staticmethod(lambda: 1e9), "sleep": staticmethod(lambda s: None)})) ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}, "overrides": {}} import pytest with pytest.raises(mod.InstallError): mod.step_health(ctx) def test_tc17_main_verify_reports_fail_as_exit_error(monkeypatch): mod = _load_module() monkeypatch.setattr(mod, "ROOT_ENV", os.path.join(str(REPO_ROOT), "nonexistent.env")) monkeypatch.setattr(mod, "_http", lambda url, headers=None, timeout=10: (503, "")) assert mod.main(["verify"]) == mod.EXIT_ERROR