feat(webui): gated action framework (#434)
Register future write actions with task_capability_map metadata, mutation-ledger previews, and fail-closed execution in the read-only MVP. Adds /actions routes, disabled UI buttons, and tests proving ungated attempts cannot invoke mutations. Closes #434
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""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()
|
||||
@@ -51,6 +51,11 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.get(path).status_code, 200)
|
||||
|
||||
def test_actions_is_implemented(self):
|
||||
response = self.client.get("/actions")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Gated actions", response.text)
|
||||
|
||||
def test_post_is_rejected(self):
|
||||
response = self.client.post("/health")
|
||||
self.assertEqual(response.status_code, 405)
|
||||
@@ -62,10 +67,12 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertIn("Live queue", response.text)
|
||||
|
||||
def test_nav_links_on_all_pages(self):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions"):
|
||||
with self.subTest(path=path):
|
||||
text = self.client.get(path).text
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for href in (
|
||||
"/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions",
|
||||
):
|
||||
self.assertIn(f'href="{href}"', text)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user