developer(ET): auto-commit from developer run_id=640
This commit is contained in:
988
tests/test_setup_lite_script.py
Normal file
988
tests/test_setup_lite_script.py
Normal file
@@ -0,0 +1,988 @@
|
||||
"""ORCH-104 (TC-01…TC-25, AC-1…AC-12): структурные и unit-проверки
|
||||
`scripts/setup_lite.py` — интерактивного installer'а Lite-тиража.
|
||||
|
||||
По образцу `tests/test_bootstrap_script.py` (ORCH-103) + ADR-001 ORCH-104 D12:
|
||||
вся решающая логика скрипта — чистые функции (вердикты предусловий,
|
||||
классификатор discovery, когерентность портов, рендер env с маркером
|
||||
managed-файла, builder аргументов onboarding, step-движок), тестируемые без
|
||||
TTY/сети/docker; интерактив — через инжектируемый I/O (`IO(input_fn=…,
|
||||
getpass_fn=…, say_fn=…, is_tty=…, env=…)`); файловые сценарии — на tmp_path;
|
||||
структурная гигиена — ast/эвристики по файлу скрипта (stdlib-only, зеркала
|
||||
delete/status-needle-наборов, кирпичи gen_secrets.py / onboard_project.py).
|
||||
|
||||
Детерминировано: без сети/docker/LLM (единственный субпроцесс — stdlib-кирпич
|
||||
gen_secrets.py в TC-13); модуль импортируется по файлу, import не имеет
|
||||
side effects.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Один источник истины запрещённых боевых литералов (TC-15, ORCH-101 AC-7).
|
||||
from tests.test_no_host_hardcodes import FORBIDDEN
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / "scripts/setup_lite.py"
|
||||
|
||||
# Зеркало FORBIDDEN_DELETE_NEEDLES bootstrap'а (ORCH-103 D9) + API/git-удаления
|
||||
# (AC-12): delete-операций в installer'е нет ВООБЩЕ — лечение всегда инструкцией.
|
||||
FORBIDDEN_DELETE_NEEDLES = (
|
||||
"volume rm",
|
||||
"rm -rf",
|
||||
"down -v",
|
||||
"compose down",
|
||||
"rmtree",
|
||||
"os.remove",
|
||||
".unlink",
|
||||
"push --delete",
|
||||
'method="DELETE"',
|
||||
"method='DELETE'",
|
||||
)
|
||||
|
||||
# Зеркало FORBIDDEN_STATUS_NEEDLES: собственный канон Plane-статусов в скрипте
|
||||
# запрещён (статусы — только кирпич onboard_project.py / plane_sync, BR-7/D11).
|
||||
FORBIDDEN_STATUS_NEEDLES = (
|
||||
"Backlog",
|
||||
"To Analyse",
|
||||
"Confirm Deploy",
|
||||
"Code-Review",
|
||||
"Awaiting Deploy",
|
||||
"Monitoring after Deploy",
|
||||
)
|
||||
|
||||
# stdlib-allowlist top-level импортов (D1: 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/setup_lite.py отсутствует (AC-1/FR-1)"
|
||||
return SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("setup_lite", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _io(mod, answers=(), secrets_=(), say=None, is_tty=True, env=None, yes=False):
|
||||
"""Инжектируемый I/O со скриптованными ответами (D10/NFR-5)."""
|
||||
answers_it = iter(answers)
|
||||
secrets_it = iter(secrets_)
|
||||
return mod.IO(
|
||||
input_fn=lambda prompt="": next(answers_it),
|
||||
getpass_fn=lambda prompt="": next(secrets_it),
|
||||
say_fn=(say.append if say is not None else (lambda s: None)),
|
||||
is_tty=is_tty,
|
||||
env=({} if env is None else env),
|
||||
yes=yes,
|
||||
)
|
||||
|
||||
|
||||
def _full_facts() -> dict:
|
||||
"""Фикстура «всё установлено» (перечень FR-2; без боевых литералов)."""
|
||||
return {
|
||||
"uname": "Linux x86_64",
|
||||
"docker": True,
|
||||
"compose_v2": True,
|
||||
"git": True,
|
||||
"python3": True,
|
||||
"node": True,
|
||||
"node_bin": "/usr/bin/node",
|
||||
"claude_code_dir": "/usr/lib/node_modules/@anthropic-ai/claude-code",
|
||||
"claude_creds_readable": True,
|
||||
"docker_gid": "984",
|
||||
"uid": 1001,
|
||||
"gid": 1001,
|
||||
"home": "/home/operator",
|
||||
"repos_dir": "/home/operator/repos",
|
||||
"repos_dir_owner_ok": True,
|
||||
"ssh_dir": "/home/operator/.orchestrator-ssh",
|
||||
"ssh_keys": True,
|
||||
"busy_ports": [],
|
||||
"pkg_manager": "apt-get",
|
||||
"repo_root": "/home/operator/orchestrator",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 / AC-1: CLI — режимы закрыты, дефолт apply (ADR-001 D2), флаги.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_modes_closed_and_apply_is_default():
|
||||
mod = _load_module()
|
||||
parser = mod.build_arg_parser()
|
||||
# ОСОЗНАННО зеркально к test_plan_is_default_mode bootstrap'а: у setup_lite
|
||||
# дефолт — apply-wizard (бизнес-цель «одна команда», ADR-001 D2); безопасность
|
||||
# дефолта — структурно (фаза 0 ≡ plan, ранний guard .env, per-action consent,
|
||||
# non-TTY без --yes → exit 2), а не выбором режима.
|
||||
assert parser.parse_args([]).mode == "apply"
|
||||
assert parser.parse_args(["plan"]).mode == "plan" # строгий read-only
|
||||
assert parser.parse_args(["verify"]).mode == "verify" # read-only пост-проверка
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["wizard"]) # набор режимов закрыт (D2)
|
||||
args = parser.parse_args([])
|
||||
assert args.force is False and args.yes is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 / AC-1: step-движок check→ensure — skip без вызова ensure.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_engine_skips_done_steps_without_ensure():
|
||||
mod = _load_module()
|
||||
ran: list = []
|
||||
steps = (
|
||||
("one", lambda ctx: False, lambda ctx: (ran.append("one"), "ok")[1]),
|
||||
("two", lambda ctx: False, lambda ctx: (ran.append("two"), "ok")[1]),
|
||||
)
|
||||
ctx = {"results": {}}
|
||||
mod.run_steps(steps, ctx)
|
||||
assert ran == ["one", "two"]
|
||||
assert ctx["results"] == {"one": "ok", "two": "ok"}
|
||||
|
||||
# повторный прогон по фикстуре «всё выполнено» — каскад skip, ни одной мутации
|
||||
ran.clear()
|
||||
done_steps = tuple((n, (lambda ctx: True), e) for n, _, e in steps)
|
||||
ctx2 = {"results": {}}
|
||||
mod.run_steps(done_steps, ctx2)
|
||||
assert ran == []
|
||||
assert set(ctx2["results"].values()) == {"skip"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 / AC-1, AC-11: resume — manual-step останавливает (exit 2), повторный
|
||||
# запуск продолжает с первого незавершённого шага.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_resume_continues_from_first_unfinished_step():
|
||||
mod = _load_module()
|
||||
ran: list = []
|
||||
done = {"a": False, "b": False}
|
||||
|
||||
def ensure_a(ctx):
|
||||
ran.append("a")
|
||||
return "ok"
|
||||
|
||||
def ensure_b(ctx):
|
||||
if not done["b"]:
|
||||
raise mod.ManualStop("b: выполните ручной шаг")
|
||||
ran.append("b")
|
||||
return "ok"
|
||||
|
||||
steps = (
|
||||
("a", lambda ctx: done["a"], ensure_a),
|
||||
("b", lambda ctx: done["b"], ensure_b),
|
||||
("c", lambda ctx: False, lambda ctx: (ran.append("c"), "ok")[1]),
|
||||
)
|
||||
with pytest.raises(mod.ManualStop):
|
||||
mod.run_steps(steps, {"results": {}})
|
||||
assert ran == ["a"] and "c" not in ran # остановились на manual-step
|
||||
|
||||
# «resume» = повторный запуск: выполненное скипается, продолжаем с первого
|
||||
# незавершённого (b теперь сделан руками → skip, c выполняется)
|
||||
done["a"] = done["b"] = True
|
||||
ran.clear()
|
||||
ctx = {"results": {}}
|
||||
mod.run_steps(steps, ctx)
|
||||
assert ran == ["c"]
|
||||
assert ctx["results"]["a"] == "skip" and ctx["results"]["b"] == "skip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 / AC-2: вердикты предусловий — полный набор фактов → все OK.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_full_facts_give_all_ok_and_no_blockers():
|
||||
mod = _load_module()
|
||||
verdicts = mod.prereq_verdicts(_full_facts())
|
||||
assert verdicts, "prereq_verdicts вернул пустой перечень (FR-2)"
|
||||
assert all(v == "OK" for _, v, _ in verdicts), verdicts
|
||||
assert mod.has_blockers(verdicts) is False
|
||||
# ни один пункт перечня FR-2 не пропускается молча
|
||||
items = {item for item, _, _ in verdicts}
|
||||
for required in ("os", "docker", "compose", "git", "python3", "node",
|
||||
"claude-code", "claude-auth", "docker-group", "repos-dir",
|
||||
"ssh", "ports"):
|
||||
assert required in items, f"пункт {required!r} перечня FR-2 не покрыт"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 / AC-2: MISSING с конкретной командой; отказ от согласия → MANUAL,
|
||||
# команда напечатана, мутация НЕ выполнена.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_missing_docker_offer_declined_is_manual_without_mutation():
|
||||
mod = _load_module()
|
||||
facts = _full_facts()
|
||||
facts.update(docker=False, compose_v2=False)
|
||||
verdicts = dict((i, (v, d)) for i, v, d in mod.prereq_verdicts(facts))
|
||||
assert verdicts["docker"][0] == "MISSING"
|
||||
assert mod.has_blockers(mod.prereq_verdicts(facts)) is True
|
||||
|
||||
command = mod.install_command("apt-get", "docker")
|
||||
assert command and "apt-get" in command # конкретная команда под менеджер
|
||||
|
||||
transcript: list = []
|
||||
executed: list = []
|
||||
io = _io(mod, answers=["n"], say=transcript) # инжектированный отказ
|
||||
status = mod.offer_install("docker", command, io,
|
||||
runner=lambda cmd: executed.append(cmd))
|
||||
assert status == "manual"
|
||||
assert executed == [], "мутация выполнена несмотря на отказ (AC-2)"
|
||||
blob = "\n".join(transcript)
|
||||
assert command in blob, "точная команда не напечатана ДО запроса согласия"
|
||||
|
||||
|
||||
def test_tc05b_offer_accepted_runs_and_rechecks():
|
||||
mod = _load_module()
|
||||
executed: list = []
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
|
||||
io = _io(mod, answers=["y"])
|
||||
status = mod.offer_install(
|
||||
"git", "sudo apt-get install -y git", io,
|
||||
runner=lambda cmd: (executed.append(cmd), _Proc())[1],
|
||||
recheck=lambda: True,
|
||||
)
|
||||
assert status == "ok" and executed == ["sudo apt-get install -y git"]
|
||||
# re-check фактом не сошёлся → честный MANUAL (не ложный OK)
|
||||
io2 = _io(mod, answers=["y"])
|
||||
status2 = mod.offer_install(
|
||||
"git", "sudo apt-get install -y git", io2,
|
||||
runner=lambda cmd: _Proc(), recheck=lambda: False,
|
||||
)
|
||||
assert status2 == "manual"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 / AC-2: неопределимый пакетный менеджер → MANUAL со ссылкой на канон;
|
||||
# uname вне контура → WARN, не падение.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_unknown_pkg_manager_and_foreign_os():
|
||||
mod = _load_module()
|
||||
assert mod.detect_pkg_manager(which=lambda name: None) is None
|
||||
assert mod.detect_pkg_manager(
|
||||
which=lambda name: "/usr/bin/dnf" if name == "dnf" else None) == "dnf"
|
||||
assert mod.install_command(None, "docker") is None
|
||||
hint = mod.manual_install_hint("docker")
|
||||
assert "LITE_SETUP" in hint # ссылка на § канона, не молчаливый пропуск
|
||||
|
||||
facts = _full_facts()
|
||||
facts["uname"] = "Darwin arm64"
|
||||
verdicts = dict((i, (v, d)) for i, v, d in mod.prereq_verdicts(facts))
|
||||
assert verdicts["os"][0] == "WARN"
|
||||
assert "вне контура Lite" in verdicts["os"][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 / AC-3: discovery — две независимые Plane-инсталляции → ровно 2
|
||||
# кандидата; выбор пользователя применяется; «ввести вручную» присутствует.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _two_plane_containers() -> list:
|
||||
return [
|
||||
{"name": "plane-a-proxy-1", "image": "makeplane/plane-proxy:v0.23.1",
|
||||
"ports": "0.0.0.0:8080->80/tcp", "project": "plane-a"},
|
||||
{"name": "plane-a-api-1", "image": "makeplane/plane-backend:v0.23.1",
|
||||
"ports": "", "project": "plane-a"},
|
||||
{"name": "plane-a-db-1", "image": "postgres:15.7-alpine",
|
||||
"ports": "", "project": "plane-a"},
|
||||
{"name": "plane-b-proxy-1", "image": "makeplane/plane-proxy:v0.23.1",
|
||||
"ports": "0.0.0.0:9090->80/tcp", "project": "plane-b"},
|
||||
{"name": "shop-nginx-1", "image": "nginx:1.25",
|
||||
"ports": "0.0.0.0:80->80/tcp", "project": "shop"},
|
||||
]
|
||||
|
||||
|
||||
def test_tc07_two_plane_installations_listed_and_choice_applied():
|
||||
mod = _load_module()
|
||||
installs = mod.discover_installations(_two_plane_containers())
|
||||
planes = [i for i in installs if i["kind"] == "plane"]
|
||||
assert len(planes) == 2, planes
|
||||
assert {p["project"] for p in planes} == {"plane-a", "plane-b"}
|
||||
assert {p["url_port"] for p in planes} == {8080, 9090}
|
||||
|
||||
transcript: list = []
|
||||
io = _io(mod, answers=["2"], say=transcript)
|
||||
chosen = mod.choose_installation("Plane", planes, io)
|
||||
assert chosen is not None and chosen["url_port"] in (8080, 9090)
|
||||
blob = "\n".join(transcript)
|
||||
assert "1." in blob and "2." in blob, "нумерованный список не показан"
|
||||
assert "вручную" in blob, "пункт «ввести вручную» отсутствует (AC-3)"
|
||||
|
||||
# «ввести вручную» доступен и при ≥2 кандидатах
|
||||
io0 = _io(mod, answers=["0"])
|
||||
assert mod.choose_installation("Plane", planes, io0) is None
|
||||
|
||||
|
||||
def test_tc08_single_zero_and_foreign_images():
|
||||
mod = _load_module()
|
||||
containers = _two_plane_containers()
|
||||
# одна инсталляция → префилл по умолчанию (Enter = подтверждение)
|
||||
single = [i for i in mod.discover_installations(containers[:3])
|
||||
if i["kind"] == "plane"]
|
||||
assert len(single) == 1
|
||||
io = _io(mod, answers=[""])
|
||||
chosen = mod.choose_installation("Plane", single, io)
|
||||
assert chosen is single[0]
|
||||
|
||||
# ноль инсталляций → ручной ввод + честная подсказка про Bundled
|
||||
transcript: list = []
|
||||
io0 = _io(mod, answers=[], say=transcript)
|
||||
assert mod.choose_installation("Plane", [], io0) is None
|
||||
assert "BUNDLED_SETUP" in "\n".join(transcript)
|
||||
|
||||
# посторонние образы кандидатами не становятся (строго image-префиксы, D5)
|
||||
foreign_only = [c for c in containers if c["project"] == "shop"]
|
||||
assert mod.discover_installations(foreign_only) == []
|
||||
|
||||
|
||||
def test_tc08b_gitea_discovery_by_image_prefix():
|
||||
mod = _load_module()
|
||||
containers = [
|
||||
{"name": "git-gitea-1", "image": "docker.gitea.com/gitea:1.24.9",
|
||||
"ports": "0.0.0.0:3300->3000/tcp", "project": "git"},
|
||||
]
|
||||
giteas = [i for i in mod.discover_installations(containers)
|
||||
if i["kind"] == "gitea"]
|
||||
assert len(giteas) == 1 and giteas[0]["url_port"] == 3300
|
||||
|
||||
|
||||
def test_tc09_docker_failure_is_never_block(monkeypatch):
|
||||
mod = _load_module()
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("docker недоступен")
|
||||
|
||||
monkeypatch.setattr(mod, "_run", _boom)
|
||||
assert mod.list_containers() is None # ошибка перечисления → None, не исключение
|
||||
# классификатор на «нет данных» отвечает пустым перечнем (ручной ввод дальше)
|
||||
assert mod.discover_installations([]) == []
|
||||
assert mod.parse_docker_ps("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 / AC-4: цикл запроса — re-prompt с диагнозом, лимит попыток, не
|
||||
# бесконечный цикл; успешная верификация → значение принято.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_verify_retry_limit_and_success():
|
||||
mod = _load_module()
|
||||
transcript: list = []
|
||||
attempts: list = []
|
||||
|
||||
def verify_fail(value):
|
||||
attempts.append(value)
|
||||
return False, "HTTP 401: токен отклонён"
|
||||
|
||||
io = _io(mod, secrets_=["t1", "t2", "t3", "t4"], say=transcript)
|
||||
with pytest.raises(mod.ManualStop):
|
||||
io.ask("ORCH_PLANE_API_TOKEN", "Plane API token", secret=True,
|
||||
verify=verify_fail, max_tries=3)
|
||||
assert len(attempts) == 3, "лимит попыток не соблюдён (не бесконечный цикл)"
|
||||
assert any("401" in line for line in transcript), "re-prompt без диагноза"
|
||||
|
||||
def verify_second(value):
|
||||
return (value == "good", "HTTP 401")
|
||||
|
||||
io2 = _io(mod, secrets_=["bad", "good"])
|
||||
assert io2.ask("ORCH_GITEA_TOKEN", "Gitea token", secret=True,
|
||||
verify=verify_second) == "good"
|
||||
|
||||
|
||||
def test_tc10b_env_prefill_is_used_and_verified():
|
||||
mod = _load_module()
|
||||
seen: list = []
|
||||
io = _io(mod, env={"ORCH_GITEA_TOKEN": "from-env"}, is_tty=False, yes=True)
|
||||
value = io.ask("ORCH_GITEA_TOKEN", "Gitea token", secret=True,
|
||||
verify=lambda v: (seen.append(v) or True, ""))
|
||||
assert value == "from-env" and seen == ["from-env"] # верификация выполняется
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 / AC-4: секрет-гигиена — значение секрета не попадает в транскрипт.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_secret_values_never_printed(capsys):
|
||||
mod = _load_module()
|
||||
secret = "supersecret-token-value-123"
|
||||
transcript: list = []
|
||||
io = _io(mod, secrets_=[secret], say=transcript)
|
||||
value = io.ask("ORCH_TELEGRAM_BOT_TOKEN", "токен бота", secret=True,
|
||||
verify=lambda v: (True, ""))
|
||||
assert value == secret
|
||||
blob = "\n".join(transcript) + capsys.readouterr().out
|
||||
assert secret not in blob, "значение секрета напечатано (NFR-3)"
|
||||
assert "ORCH_TELEGRAM_BOT_TOKEN" in blob or transcript == [], (
|
||||
"в транскрипте допустимы только имена ключей"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 / AC-4: non-TTY без альтернативы → честный отказ, не зависание.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc12_non_tty_is_deterministic_refusal():
|
||||
mod = _load_module()
|
||||
io = _io(mod, is_tty=False)
|
||||
with pytest.raises(mod.ManualStop):
|
||||
io.ask("ORCH_PLANE_API_TOKEN", "Plane API token", secret=True)
|
||||
# consent в non-TTY без --yes — тоже честный отказ
|
||||
with pytest.raises(mod.ManualStop):
|
||||
io.consent("выполнить мутацию")
|
||||
# с --yes согласие дано флагом (headless-consent, D10)
|
||||
io_yes = _io(mod, is_tty=False, yes=True)
|
||||
assert io_yes.consent("выполнить мутацию") is True
|
||||
# non-TTY + непустой дефолт (автодетект) → дефолт принимается, прогон жив
|
||||
io_def = _io(mod, is_tty=False, yes=True)
|
||||
assert io_def.ask("ORCH_RUN_UID", "uid", default="1001") == "1001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 / AC-5: сборка .env/.env.watchdog — группы ключей, файл-носитель §4.3,
|
||||
# свежие 64-hex webhook-секреты кирпичом gen_secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_split_overrides_watchdog_keys_only_in_watchdog_file():
|
||||
mod = _load_module()
|
||||
answers = {
|
||||
"ORCH_PLANE_API_URL": "http://127.0.0.1:8080",
|
||||
"ORCH_TELEGRAM_BOT_TOKEN": "tg-orch",
|
||||
"WATCHDOG_TG_BOT_TOKEN": "tg-wd",
|
||||
"WATCHDOG_TG_CHAT_ID": "42",
|
||||
"WATCHDOG_METRICS_URL": "http://127.0.0.1:8500/metrics",
|
||||
}
|
||||
root_ov, wd_ov = mod.split_overrides(answers)
|
||||
assert "WATCHDOG_TG_BOT_TOKEN" not in root_ov # ловушка файла-носителя §4.3
|
||||
assert "WATCHDOG_TG_CHAT_ID" not in root_ov
|
||||
assert wd_ov["WATCHDOG_TG_BOT_TOKEN"] == "tg-wd"
|
||||
assert wd_ov["WATCHDOG_TG_CHAT_ID"] == "42"
|
||||
assert root_ov["ORCH_PLANE_API_URL"] == "http://127.0.0.1:8080"
|
||||
# когерентный WATCHDOG_METRICS_URL уходит в файл-носитель sidecar'а
|
||||
assert wd_ov["WATCHDOG_METRICS_URL"] == "http://127.0.0.1:8500/metrics"
|
||||
|
||||
|
||||
def test_tc13b_webhook_secrets_are_fresh_64_hex_via_brick():
|
||||
mod = _load_module()
|
||||
first = mod.issue_webhook_secrets()
|
||||
second = mod.issue_webhook_secrets()
|
||||
for batch in (first, second):
|
||||
assert set(batch) == {"ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET"}
|
||||
for value in batch.values():
|
||||
assert len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
||||
assert first != second, "повторный выпуск обязан давать другие значения (AC-5)"
|
||||
|
||||
|
||||
def test_tc13c_render_env_keeps_canon_and_adds_marker():
|
||||
mod = _load_module()
|
||||
example = "# шапка канона\nA=1\nB=\n"
|
||||
rendered = mod.render_env(example, {"B": "v", "NEW": "n"})
|
||||
assert rendered.startswith(mod.MANAGED_MARKER), "маркер managed-файла (D6)"
|
||||
assert "# шапка канона" in rendered and "A=1" in rendered
|
||||
assert mod.parse_env(rendered) == {"A": "1", "B": "v", "NEW": "n"}
|
||||
|
||||
|
||||
def test_tc13d_mandatory_key_groups_covered():
|
||||
"""Карта обязательных ключей §4.2 LITE_SETUP покрыта константой скрипта."""
|
||||
mod = _load_module()
|
||||
keys = set(mod.MANDATORY_NEW_HOST_KEYS)
|
||||
for required in (
|
||||
"ORCH_PLANE_API_URL", "ORCH_PLANE_WEB_URL", "ORCH_PLANE_WORKSPACE_SLUG",
|
||||
"ORCH_PLANE_API_TOKEN", "ORCH_GITEA_URL", "ORCH_GITEA_PUBLIC_URL",
|
||||
"ORCH_GITEA_OWNER", "ORCH_GITEA_TOKEN", "ORCH_PLANE_WEBHOOK_SECRET",
|
||||
"ORCH_GITEA_WEBHOOK_SECRET", "ORCH_TELEGRAM_BOT_TOKEN",
|
||||
"ORCH_TELEGRAM_CHAT_ID", "ORCH_RUN_UID", "ORCH_RUN_GID",
|
||||
"ORCH_DOCKER_GID", "ORCH_HOST_REPOS_DIR",
|
||||
"ORCH_DEPLOY_PROD_TARGET_PORT", "ORCH_STAGING_PORT",
|
||||
):
|
||||
assert required in keys, f"{required} не покрыт картой §4.2 (AC-5)"
|
||||
# все имена — канонические (существуют в .env.example)
|
||||
canon = set()
|
||||
for line in (REPO_ROOT / ".env.example").read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
canon.add(line.split("=", 1)[0].strip())
|
||||
assert keys <= canon, f"вне канона .env.example: {sorted(keys - canon)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 / AC-5: существующий чужой .env → отказ без force, байт-в-байт; маркер
|
||||
# managed-файла → resume-ensure; с force — перезапись.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_existing_foreign_env_is_refused_byte_identical(tmp_path):
|
||||
mod = _load_module()
|
||||
env = tmp_path / ".env"
|
||||
env.write_text("MY_LIVE_KEY=1\n", encoding="utf-8")
|
||||
before = env.read_bytes()
|
||||
assert mod.env_file_state(env.read_text(encoding="utf-8")) == "foreign"
|
||||
assert mod.env_file_state(None) == "absent"
|
||||
|
||||
io = _io(mod, answers=["y"])
|
||||
with pytest.raises(mod.ManualStop):
|
||||
mod.ensure_env_file(str(env), "A=\n", {"A": "x"}, force=False, io=io)
|
||||
assert env.read_bytes() == before, "чужой .env изменён без force (NFR-7)"
|
||||
|
||||
# с явным force (+согласие) — перезапись выполняется
|
||||
io2 = _io(mod, answers=["y"])
|
||||
mod.ensure_env_file(str(env), "A=\n", {"A": "x"}, force=True, io=io2)
|
||||
text = env.read_text(encoding="utf-8")
|
||||
assert text.startswith(mod.MANAGED_MARKER) and "A=x" in text
|
||||
|
||||
|
||||
def test_tc14b_managed_file_resume_ensure_keeps_existing_values(tmp_path):
|
||||
mod = _load_module()
|
||||
env = tmp_path / ".env"
|
||||
env.write_text(mod.MANAGED_MARKER + "\nA=keep\nB=\n", encoding="utf-8")
|
||||
assert mod.env_file_state(env.read_text(encoding="utf-8")) == "managed"
|
||||
io = _io(mod, answers=[])
|
||||
mod.ensure_env_file(str(env), "A=\nB=\nC=\n", {"A": "new", "B": "vb", "C": "vc"},
|
||||
force=False, io=io)
|
||||
got = mod.parse_env(env.read_text(encoding="utf-8"))
|
||||
assert got["A"] == "keep", "resume-ensure перетёр существующее значение (D6)"
|
||||
assert got["B"] == "vb" and got["C"] == "vc", "недостающие ключи не дозаполнены"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 / AC-5: подсказки-дефолты — только автодетект/канон, без боевых значений.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_prompt_defaults_come_from_autodetect_without_forbidden():
|
||||
mod = _load_module()
|
||||
defaults = mod.prompt_defaults(_full_facts())
|
||||
blob = json.dumps(defaults, ensure_ascii=False)
|
||||
for literal in FORBIDDEN:
|
||||
assert literal not in blob, f"боевой литерал {literal!r} в дефолтах промптов"
|
||||
assert defaults["ORCH_RUN_UID"] == "1001"
|
||||
assert defaults["ORCH_DOCKER_GID"] == "984"
|
||||
assert defaults["ORCH_HOST_NODE_BIN"] == "/usr/bin/node"
|
||||
assert defaults["ORCH_AGENT_HOME_DIR"] == "/home/operator"
|
||||
assert defaults["ORCH_HOST_CLAUDE_DIR"] == "/home/operator/.claude"
|
||||
|
||||
|
||||
def test_tc15b_script_source_carries_no_forbidden_literals():
|
||||
src = _source()
|
||||
offenders = [literal for literal in FORBIDDEN if literal in src]
|
||||
assert not offenders, f"боевые литералы в setup_lite.py: {offenders}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 / AC-6: когерентная тройка портов одной функцией; занятый порт →
|
||||
# альтернатива.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_port_overrides_keep_triple_coherent():
|
||||
mod = _load_module()
|
||||
overrides = mod.port_overrides(8700)
|
||||
assert overrides["ORCH_DEPLOY_PROD_TARGET_PORT"] == "8700"
|
||||
assert overrides["WATCHDOG_METRICS_URL"] == "http://127.0.0.1:8700/metrics"
|
||||
assert overrides["ORCH_POST_DEPLOY_BASE_URL"] == "http://localhost:8700"
|
||||
# занятый порт → предложена альтернатива (мок busy-check)
|
||||
assert mod.next_free_port(8500, busy=lambda p: p in (8500, 8501)) == 8502
|
||||
|
||||
|
||||
def test_tc17_staging_port_equal_to_prod_is_fail_closed():
|
||||
mod = _load_module()
|
||||
assert mod.staging_port_ok(8500, 8500) is False # инвариант ORCH-058/101
|
||||
assert mod.staging_port_ok(8501, 8500) is True
|
||||
# в цикле ввода значение не принимается — re-prompt до различного
|
||||
io = _io(mod, answers=["8500", "8501"])
|
||||
value = io.ask("ORCH_STAGING_PORT", "staging-порт", default="8501",
|
||||
verify=lambda v: (mod.staging_port_ok(int(v), 8500),
|
||||
"staging-порт обязан отличаться от прод-порта"))
|
||||
assert value == "8501"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 / AC-7: C-1 — одинаковые токены орка и watchdog запрещены машинно.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_telegram_c1_identical_tokens_refused():
|
||||
mod = _load_module()
|
||||
ok, hint = mod.telegram_c1_verdict("same-token", "same-token")
|
||||
assert ok is False
|
||||
assert "C-1" in hint or "ЗАПРЕЩЕНО" in hint.upper(), "отказ без объяснения запрета"
|
||||
ok2, _ = mod.telegram_c1_verdict("token-a", "token-b")
|
||||
assert ok2 is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-19 / AC-8: branch protection — честный FAIL с лечением, без удаления.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc19_branch_protection_verdict():
|
||||
mod = _load_module()
|
||||
ok, hint = mod.branch_protection_verdict(200, [{"branch_name": "main"}])
|
||||
assert ok is False
|
||||
assert "6.4" in hint or "13.7" in hint, "лечение без ссылки на норматив §6.4"
|
||||
assert mod.branch_protection_verdict(200, [])[0] is True
|
||||
# репо ещё не создан (создаст onboarding) — не FAIL
|
||||
assert mod.branch_protection_verdict(404, None)[0] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-20 / AC-8 (D8): webhook Plane Path Б — только с явного согласия; отказ →
|
||||
# UI-путь, мутирующий вызов не произведён; после согласия — пост-верификация.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _webhook_answers() -> dict:
|
||||
return {
|
||||
"plane_db_container": "plane-a-db-1",
|
||||
"plane_db_user": "plane",
|
||||
"plane_db_name": "plane",
|
||||
"plane_db_password": "pw",
|
||||
"ORCH_PLANE_WORKSPACE_SLUG": "acme",
|
||||
"ORCH_PLANE_WEBHOOK_SECRET": "a" * 64,
|
||||
"orchestrator_public_url": "https://orch.example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_tc20_path_b_refused_no_mutation():
|
||||
mod = _load_module()
|
||||
calls: list = []
|
||||
|
||||
def psql(sql):
|
||||
calls.append(sql)
|
||||
return 0, "0"
|
||||
|
||||
transcript: list = []
|
||||
io = _io(mod, answers=["n"], say=transcript) # отказ от согласия на SQL
|
||||
status = mod.plane_webhook_path_b(_webhook_answers(), io, psql)
|
||||
assert status == "manual"
|
||||
assert not any("INSERT" in c.upper() for c in calls), (
|
||||
"мутирующий SQL выполнен без согласия (D8)"
|
||||
)
|
||||
assert "INSERT" in "\n".join(transcript).upper(), (
|
||||
"точный SQL не показан ДО запроса согласия (D8 п.2)"
|
||||
)
|
||||
|
||||
|
||||
def test_tc20b_path_b_consent_insert_and_postverify():
|
||||
mod = _load_module()
|
||||
state = {"rows": 0}
|
||||
calls: list = []
|
||||
|
||||
def psql(sql):
|
||||
calls.append(sql)
|
||||
if "INSERT" in sql.upper():
|
||||
state["rows"] = 1
|
||||
return 0, ""
|
||||
if "count(*)" in sql:
|
||||
return 0, str(state["rows"])
|
||||
if "SELECT id FROM workspaces" in sql:
|
||||
return 0, "11111111-2222-3333-4444-555555555555"
|
||||
return 0, ("https://orch.example.com/webhook/plane|t" if state["rows"] else "")
|
||||
|
||||
io = _io(mod, answers=["y"])
|
||||
assert mod.plane_webhook_path_b(_webhook_answers(), io, psql) == "ok"
|
||||
assert any("INSERT" in c.upper() for c in calls)
|
||||
# пост-верификация обязательна: INSERT прошёл, но строки нет → НЕ PASS
|
||||
io2 = _io(mod, answers=["y"])
|
||||
|
||||
def psql_lost(sql):
|
||||
if "count(*)" in sql:
|
||||
return 0, "0"
|
||||
if "SELECT id FROM workspaces" in sql:
|
||||
return 0, "11111111-2222-3333-4444-555555555555"
|
||||
if "INSERT" in sql.upper():
|
||||
return 0, ""
|
||||
return 0, ""
|
||||
|
||||
assert mod.plane_webhook_path_b(_webhook_answers(), io2, psql_lost) == "manual"
|
||||
|
||||
|
||||
def test_tc20c_path_b_is_idempotent_skip_when_registered():
|
||||
mod = _load_module()
|
||||
calls: list = []
|
||||
|
||||
def psql(sql):
|
||||
calls.append(sql)
|
||||
return 0, "1" # webhook уже зарегистрирован
|
||||
|
||||
io = _io(mod, answers=["y"])
|
||||
assert mod.plane_webhook_path_b(_webhook_answers(), io, psql) == "skipped"
|
||||
assert not any("INSERT" in c.upper() for c in calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-21 / AC-9: запуск — up только после согласия; состав «ровно орк+watchdog»;
|
||||
# health-контракты; stateless-проверка.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc21_composition_verdict_is_exactly_two_services():
|
||||
mod = _load_module()
|
||||
ok, _ = mod.lite_composition_verdict(["orchestrator", "orchestrator-watchdog"])
|
||||
assert ok is True
|
||||
bad, hint = mod.lite_composition_verdict(
|
||||
["orchestrator", "orchestrator-watchdog", "orchestrator-staging"])
|
||||
assert bad is False and "staging" in hint
|
||||
assert mod.lite_composition_verdict(["orchestrator"])[0] is False
|
||||
|
||||
|
||||
def test_tc21b_health_checks_require_contract_bodies():
|
||||
mod = _load_module()
|
||||
bodies = {
|
||||
"/health": (200, '{"status":"ok"}'),
|
||||
"/queue": (200, '{"counts": {}, "recent": []}'),
|
||||
"/metrics": (200, '{"schema_version": 1}'),
|
||||
}
|
||||
|
||||
def http(url, headers=None, timeout=10):
|
||||
for path, resp in bodies.items():
|
||||
if url.endswith(path):
|
||||
return resp
|
||||
return None, ""
|
||||
|
||||
results = mod.health_checks(http, 8500)
|
||||
assert all(ok for _, ok, _ in results), results
|
||||
bodies["/metrics"] = (200, '{"schema_version": 2}')
|
||||
results2 = mod.health_checks(http, 8500)
|
||||
assert any(path == "/metrics" and not ok for path, ok, _ in results2)
|
||||
|
||||
|
||||
def test_tc21c_step_up_requires_consent_and_checks_composition(monkeypatch):
|
||||
mod = _load_module()
|
||||
compose_calls: list = []
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
stdout = "orchestrator\norchestrator-watchdog\n"
|
||||
|
||||
monkeypatch.setattr(mod, "_compose",
|
||||
lambda *args, **kw: (compose_calls.append(args), _Proc())[1])
|
||||
monkeypatch.setattr(mod, "_http", lambda url, **kw: (
|
||||
(200, '{"status":"ok"}') if url.endswith("/health")
|
||||
else (200, '{"schema_version": 1}') if url.endswith("/metrics")
|
||||
else (200, '{"counts": {}, "recent": []}')))
|
||||
ctx = {"io": _io(mod, answers=["y"]), "answers": {}, "results": {}}
|
||||
assert mod.step_up(ctx) == "ok"
|
||||
assert any("up" in c for c in compose_calls), "compose up не вызван"
|
||||
|
||||
# отказ от согласия → MANUAL, ни одного вызова compose-мутации
|
||||
compose_calls.clear()
|
||||
ctx2 = {"io": _io(mod, answers=["n"]), "answers": {}, "results": {}}
|
||||
assert mod.step_up(ctx2) == "manual"
|
||||
assert not any("up" in c for c in compose_calls), "контур поднят без согласия"
|
||||
|
||||
|
||||
def test_tc21d_step_up_fails_on_staging_in_composition(monkeypatch):
|
||||
mod = _load_module()
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
stdout = "orchestrator\norchestrator-watchdog\norchestrator-staging\n"
|
||||
|
||||
monkeypatch.setattr(mod, "_compose", lambda *args, **kw: _Proc())
|
||||
ctx = {"io": _io(mod, answers=["y"]), "answers": {}, "results": {}}
|
||||
with pytest.raises(mod.SetupError):
|
||||
mod.step_up(ctx)
|
||||
|
||||
|
||||
def test_tc21e_stateless_verdict_flags_foreign_tasks():
|
||||
mod = _load_module()
|
||||
clean = {"counts": {"queued": 0, "running": 0}, "recent": []}
|
||||
assert mod.stateless_verdict(clean, own_prefixes=("SHP",))[0] is True
|
||||
dirty = {"counts": {"done": 3},
|
||||
"recent": [{"work_item_id": "FOO-61"}]}
|
||||
ok, hint = mod.stateless_verdict(dirty, own_prefixes=("SHP",))
|
||||
assert ok is False and "FOO-61" in hint
|
||||
own = {"counts": {"done": 1}, "recent": [{"work_item_id": "SHP-1"}]}
|
||||
assert mod.stateless_verdict(own, own_prefixes=("SHP",))[0] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-22 / AC-10: onboarding — builder аргументов чистой функцией; строго
|
||||
# последовательность plan → согласие → apply → verify; exit 2 кирпича → MANUAL.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _project_answers(tmp_path=None) -> dict:
|
||||
return {
|
||||
"project_name": "Shop",
|
||||
"project_description": "магазин",
|
||||
"project_repo": "shop",
|
||||
"project_prefix": "SHP",
|
||||
"project_stack": "python",
|
||||
"project_test_cmd": "pytest -q",
|
||||
"project_prod_port": "8600",
|
||||
"project_staging_port": "8601",
|
||||
"ORCH_GITEA_OWNER": "acme",
|
||||
"orchestrator_public_url": "https://orch.example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_tc22_build_onboard_args_is_pure_and_complete():
|
||||
mod = _load_module()
|
||||
args = mod.build_onboard_args(_project_answers(), "plan")
|
||||
assert args[0].endswith("onboard_project.py") and args[1] == "plan"
|
||||
pairs = dict(zip(args[2::2], args[3::2]))
|
||||
assert pairs["--name"] == "Shop" and pairs["--repo"] == "shop"
|
||||
assert pairs["--prefix"] == "SHP" and pairs["--stack"] == "python"
|
||||
assert pairs["--test-cmd"] == "pytest -q"
|
||||
assert pairs["--prod-port"] == "8600" and pairs["--staging-port"] == "8601"
|
||||
assert pairs["--webhook-url"] == "https://orch.example.com/webhook/gitea"
|
||||
assert pairs["--gitea-owner"] == "acme"
|
||||
assert "--json" in args
|
||||
# детерминизм чистой функции
|
||||
assert args == mod.build_onboard_args(_project_answers(), "plan")
|
||||
|
||||
|
||||
def test_tc22b_onboard_sequence_plan_consent_apply_verify(tmp_path, monkeypatch):
|
||||
mod = _load_module()
|
||||
modes: list = []
|
||||
report = {"steps": [], "instructions": [
|
||||
"Добавь/обнови строку в .env оркестратора: ORCH_PROJECTS_JSON="
|
||||
'[{"repo": "shop"}]',
|
||||
]}
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, rc, out=""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
mode = next((m for m in ("plan", "apply", "verify") if m in cmd), "?")
|
||||
modes.append(mode)
|
||||
if mode == "apply":
|
||||
return _Proc(0, json.dumps(report))
|
||||
if mode == "verify":
|
||||
return _Proc(2) # остались ручные шаги
|
||||
return _Proc(0, "план...")
|
||||
|
||||
env = tmp_path / ".env"
|
||||
env.write_text(mod.MANAGED_MARKER + "\nORCH_PROJECTS_JSON=\n", encoding="utf-8")
|
||||
monkeypatch.setattr(mod, "_run", fake_run)
|
||||
monkeypatch.setattr(mod, "_ensure_venv", lambda: "python3")
|
||||
monkeypatch.setattr(mod, "_http", lambda url, **kw: (200, '{"counts": {}, "recent": []}'))
|
||||
monkeypatch.setattr(mod, "_compose", lambda *a, **kw: _Proc(0))
|
||||
ctx = {
|
||||
"io": _io(mod, answers=["y", "y", "y"]),
|
||||
"answers": _project_answers(),
|
||||
"results": {},
|
||||
"paths": {"root_env": str(env),
|
||||
"root_env_example": str(REPO_ROOT / ".env.example")},
|
||||
"args": mod.build_arg_parser().parse_args([]),
|
||||
}
|
||||
status = mod.step_onboard(ctx)
|
||||
assert modes[:2] == ["plan", "apply"] and "verify" in modes, (
|
||||
f"последовательность кирпича нарушена: {modes}"
|
||||
)
|
||||
assert status == "manual", "exit 2 кирпича обязан транслироваться как MANUAL"
|
||||
got = mod.parse_env(env.read_text(encoding="utf-8"))
|
||||
assert got["ORCH_PROJECTS_JSON"] == '[{"repo": "shop"}]'
|
||||
|
||||
|
||||
def test_tc22c_onboard_consent_refused_no_apply(tmp_path, monkeypatch):
|
||||
mod = _load_module()
|
||||
modes: list = []
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stdout = "план..."
|
||||
stderr = ""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
modes.append(next((m for m in ("plan", "apply", "verify") if m in cmd), "?"))
|
||||
return _Proc()
|
||||
|
||||
monkeypatch.setattr(mod, "_run", fake_run)
|
||||
monkeypatch.setattr(mod, "_ensure_venv", lambda: "python3")
|
||||
env = tmp_path / ".env"
|
||||
env.write_text(mod.MANAGED_MARKER + "\n", encoding="utf-8")
|
||||
ctx = {
|
||||
"io": _io(mod, answers=["n"]), # план показан → отказ
|
||||
"answers": _project_answers(),
|
||||
"results": {},
|
||||
"paths": {"root_env": str(env),
|
||||
"root_env_example": str(REPO_ROOT / ".env.example")},
|
||||
"args": mod.build_arg_parser().parse_args([]),
|
||||
}
|
||||
assert mod.step_onboard(ctx) == "manual"
|
||||
assert modes == ["plan"], f"apply вызван без согласия после плана: {modes}"
|
||||
|
||||
|
||||
def test_tc22d_extract_projects_json_from_brick_instructions():
|
||||
mod = _load_module()
|
||||
instructions = [
|
||||
"что-то ещё",
|
||||
'Добавь/обнови строку: ORCH_PROJECTS_JSON=[{"repo": "shop"}]',
|
||||
]
|
||||
assert mod.extract_projects_json(instructions) == '[{"repo": "shop"}]'
|
||||
assert mod.extract_projects_json([]) == ""
|
||||
assert mod.extract_projects_json(["без реестра"]) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-23 / AC-11: контракт exit-кодов — именованные константы 0/2/1.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc23_exit_code_contract():
|
||||
mod = _load_module()
|
||||
assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1)
|
||||
assert mod.exit_code_for({"a": "ok", "b": "skip"}) == 0
|
||||
assert mod.exit_code_for({"a": "ok", "b": "manual"}) == 2
|
||||
assert mod.exit_code_for({}) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-24 / AC-12: ast-скан — stdlib-only, без модулей платформы; кирпичи и канон
|
||||
# упомянуты.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc24_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 импорты в setup_lite.py (D1): {sorted(set(offenders))}"
|
||||
|
||||
|
||||
def test_tc24b_no_platform_imports():
|
||||
src = _source()
|
||||
assert "from src" not in src and "import src" not in src, (
|
||||
"setup_lite обязан быть stdlib-only без импортов платформы (D1)"
|
||||
)
|
||||
|
||||
|
||||
def test_tc24c_canonical_bricks_and_doc_are_referenced():
|
||||
src = _source()
|
||||
assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py"
|
||||
assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py"
|
||||
assert "LITE_SETUP.md" in src, "каждый шаг обязан ссылаться на канон LITE_SETUP.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-25 / AC-12: эвристический скан — delete-операций нет; собственного канона
|
||||
# статусов нет; import модуля без side effects.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc25_no_delete_operations():
|
||||
src = _source()
|
||||
offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src]
|
||||
assert not offenders, (
|
||||
f"delete-операции в setup_lite запрещены (no-delete, D1): {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_tc25b_no_own_status_canon():
|
||||
src = _source()
|
||||
offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src]
|
||||
assert not offenders, (
|
||||
f"setup_lite несёт собственный канон статусов (дрейф BR-7): {offenders}; "
|
||||
"статусы — только onboard_project.py/plane_sync"
|
||||
)
|
||||
|
||||
|
||||
def test_tc25c_module_import_has_no_side_effects():
|
||||
mod1 = _load_module()
|
||||
mod2 = _load_module()
|
||||
assert mod1.build_plan() == mod2.build_plan()
|
||||
|
||||
|
||||
def test_apply_steps_match_normative_plan():
|
||||
"""Инвариант D3: имена step-движка = нормативному плану (10 шагов)."""
|
||||
mod = _load_module()
|
||||
plan = mod.build_plan()
|
||||
assert len(plan) == 10, f"нормативный план D3 — 10 шагов, получено {len(plan)}"
|
||||
assert [name for name, _, _ in mod.APPLY_STEPS] == [name for name, _ in plan]
|
||||
assert plan[0][0] == "scan" and plan[-1][0] == "report"
|
||||
Reference in New Issue
Block a user