"""ORCH-6: Plane webhook project-filter + repo-resolution tests. Verifies the core of the 2026-06-02 incident fix: * webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task * webhook from the orchestrator project -> task created with repo=orchestrator * webhook from the enduro project -> task created with repo=enduro-trails launcher.launch is mocked so no real agents are spawned. Gitea branch/doc creation is mocked (network). FastAPI TestClient drives the real endpoint. This module configures its own registry via monkeypatch + reload_projects so it is independent of ORCH_PROJECTS_JSON set by other test modules. """ import os import tempfile import pytest # Test DB / disable signature checks (same convention as test_webhooks.py). _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.db") os.environ["ORCH_DB_PATH"] = _test_db os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from unittest.mock import patch, AsyncMock # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from src.main import app # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import projects as P # noqa: E402 from src.projects import reload_projects # noqa: E402 import src.plane_sync as plane_sync # noqa: E402 ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" # ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via # get_project_states(project_id). Hardcoding the enduro in_progress UUID for an # ORCH-project payload no longer matches, so the pipeline never starts. We mock # get_project_states with a deterministic per-project map (no network) and send # each request with the UUID that matches its own project. _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", }, } def _fake_get_project_states(project_id): """Deterministic per-project state map; mirrors get_project_states' fallback for unknown projects so the webhook still behaves sensibly.""" return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID]) client = TestClient(app) @pytest.fixture(autouse=True) def setup(monkeypatch): """Fresh DB + a known two-project registry for each test.""" # settings.db_path is resolved once at import; force it to our isolated DB so # this suite is independent of whichever test module imported config first. monkeypatch.setattr(P.settings, "db_path", _test_db) import src.db as _db monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() # The webhook signature secret may be baked into the runtime env; this suite # focuses on the project filter, so bypass signature verification. monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) # ORCH-39: resolve Plane states deterministically per-project (no network) # and start from a clean per-project cache so suites don't leak into each # other. plane.py imports get_project_states locally from ..plane_sync, so # patch it at the src.plane_sync source. plane_sync.reload_project_states() monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states) registry_json = ( f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f' "work_item_prefix": "ET", "name": "enduro-trails"}},' f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",' f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]' ) monkeypatch.setattr(P.settings, "projects_json", registry_json) reload_projects() yield reload_projects() # restore from env plane_sync.reload_project_states() if os.path.exists(_test_db): os.unlink(_test_db) # Feature 1: the pipeline now starts on a status change to In Progress (not on # creation). _post_created drives that status-change event so these ORCH-6 # routing tests still exercise task creation through the new trigger. # ORCH-39: the in_progress UUID is now project-specific, so derive it from the # project being posted to (matches get_project_states resolution above). def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"): in_progress = _fake_get_project_states(plane_project_id)["in_progress"] return client.post( "/webhook/plane", json={ "event": "issue", "action": "updated", "data": { "id": plane_id, "name": name, "description_stripped": "This is a sufficiently long description.", "project": plane_project_id, "state": {"id": in_progress, "name": "In Progress", "group": "started"}, }, }, ) # --------------------------------------------------------------------------- # Filter: unknown project is ignored, no side effects # --------------------------------------------------------------------------- @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher): resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me") assert resp.status_code == 200 assert resp.json()["status"] == "ignored" assert resp.json().get("reason") == "unknown project" # No task, no branch, no agent. conn = get_db() task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone() conn.close() assert task is None mock_branch.assert_not_called() mock_launcher.launch.assert_not_called() # --------------------------------------------------------------------------- # orchestrator project -> repo=orchestrator, prefix ORCH # --------------------------------------------------------------------------- @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher): mock_launcher.launch.return_value = 1 resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1") assert resp.status_code == 200 assert resp.json()["status"] == "accepted" conn = get_db() task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone() conn.close() assert task is not None assert task["repo"] == "orchestrator" assert task["work_item_id"].startswith("ORCH-") assert task["stage"] == "analysis" # Branch created against the orchestrator repo. args = mock_branch.call_args.args assert args[0] == "orchestrator" # --------------------------------------------------------------------------- # enduro project -> repo=enduro-trails, prefix ET # --------------------------------------------------------------------------- @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher): mock_launcher.launch.return_value = 1 resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1") assert resp.status_code == 200 assert resp.json()["status"] == "accepted" conn = get_db() task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone() conn.close() assert task is not None assert task["repo"] == "enduro-trails" assert task["work_item_id"].startswith("ET-") args = mock_branch.call_args.args assert args[0] == "enduro-trails" # --------------------------------------------------------------------------- # prefixes are independent per repo (ORCH-001 vs ET-001 in parallel) # --------------------------------------------------------------------------- @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher): mock_launcher.launch.return_value = 1 _post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one") _post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one") _post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two") conn = get_db() rows = {r["plane_id"]: r["work_item_id"] for r in conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()} conn.close() assert rows["o1"] == "ORCH-001" assert rows["o2"] == "ORCH-002" assert rows["e1"] == "ET-001"