"""Tests for web UI gated action framework (#434).""" import sys import unittest from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from starlette.testclient import TestClient from task_capability_map import TASK_CAPABILITY_MAP from webui.app import create_app from webui.gated_actions import ( attempt_action, build_action_registry, load_action_registry, preview_action, ) class TestGatedActionRegistry(unittest.TestCase): def test_all_actions_disabled_by_default(self): registry = build_action_registry() self.assertGreater(len(registry.actions), 0) for action in registry.actions: with self.subTest(action=action.action_id): self.assertFalse(action.enabled) def test_actions_align_with_task_capability_map(self): registry = build_action_registry() for action in registry.actions: with self.subTest(task=action.task_key): self.assertIn(action.task_key, TASK_CAPABILITY_MAP) self.assertEqual( action.required_permission, TASK_CAPABILITY_MAP[action.task_key]["permission"], ) self.assertEqual( action.required_role, TASK_CAPABILITY_MAP[action.task_key]["role"], ) def test_preview_includes_mutation_ledger(self): preview = preview_action("merge_pr", pr_number=42) self.assertNotIn("error", preview) ledger = preview["mutation_ledger"] self.assertEqual(len(ledger), 1) self.assertEqual(ledger[0]["mcp_tool"], "gitea_merge_pr") self.assertEqual(ledger[0]["permission"], "gitea.pr.merge") self.assertIn("42", ledger[0]["target"]) def test_attempt_fails_closed_without_mcp_calls(self): registry = build_action_registry() with patch("webui.gated_actions._MVP_ACTIONS_ENABLED", False): for action in registry.actions: with self.subTest(action=action.action_id): result = attempt_action(action.action_id, issue_number=1) self.assertFalse(result["success"]) self.assertEqual(result["error"], "action_disabled") self.assertIn("preview", result) def test_attempt_module_has_no_mcp_imports(self): """Ungated execution path must not import MCP server tools.""" import webui.gated_actions as ga source_path = Path(ga.__file__).resolve() source = source_path.read_text(encoding="utf-8") self.assertNotIn("mcp_server", source) result = attempt_action("merge_pr", pr_number=99) self.assertFalse(result["success"]) def test_unknown_action_fails_closed(self): result = attempt_action("nonexistent_action") self.assertFalse(result["success"]) self.assertEqual(result["error"], "unknown_action") class TestGatedActionRoutes(unittest.TestCase): def setUp(self): self.client = TestClient(create_app()) def test_actions_page_renders_disabled_buttons(self): response = self.client.get("/actions") self.assertEqual(response.status_code, 200) self.assertIn("Gated actions", response.text) self.assertIn("disabled", response.text.lower()) self.assertIn("mutation ledger", response.text.lower()) self.assertIn("aria-disabled", response.text) self.assertIn(" disabled", response.text) def test_api_actions_registry(self): response = self.client.get("/api/actions") self.assertEqual(response.status_code, 200) data = response.json() self.assertFalse(data["mvp_actions_enabled"]) action_ids = {item["action_id"] for item in data["actions"]} self.assertIn("merge_pr", action_ids) self.assertIn("claim_issue", action_ids) for item in data["actions"]: self.assertFalse(item["enabled"]) def test_api_action_preview_json(self): response = self.client.get( "/api/actions/review_pr/preview?pr_number=12" ) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["task_key"], "review_pr") self.assertEqual(data["required_role"], "reviewer") self.assertEqual(len(data["mutation_ledger"]), 1) def test_post_attempt_fails_closed(self): response = self.client.post( "/api/actions/merge_pr/attempt", json={"pr_number": 1}, ) self.assertEqual(response.status_code, 403) data = response.json() self.assertFalse(data["success"]) self.assertEqual(data["error"], "action_disabled") def test_nav_includes_actions_link(self): text = self.client.get("/").text self.assertIn('href="/actions"', text) if __name__ == "__main__": unittest.main()