feat: implement project bus MVP
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from project_bus.auth import StaticTokenAuthProvider, TokenIdentity
|
||||
from project_bus.db import Database
|
||||
from project_bus.mcp import MCPApplication
|
||||
from project_bus.server import ProjectBusHTTPServer
|
||||
from project_bus.service import ProjectBusService
|
||||
|
||||
|
||||
class MCPHTTPTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
service = ProjectBusService(Database(Path(self.temporary.name) / "bus.db"))
|
||||
service.initialize()
|
||||
auth = StaticTokenAuthProvider(
|
||||
{"bootstrap-secret-at-least-24": TokenIdentity("system", "system", "System")}
|
||||
)
|
||||
self.server = ProjectBusHTTPServer(
|
||||
("127.0.0.1", 0), MCPApplication(service), auth, {"https://chat.example"}
|
||||
)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.url = f"http://127.0.0.1:{self.server.server_port}/mcp"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
self.temporary.cleanup()
|
||||
|
||||
def request(
|
||||
self,
|
||||
payload: dict,
|
||||
*,
|
||||
token: str | None = None,
|
||||
origin: str | None = None,
|
||||
) -> tuple[int, dict]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if origin:
|
||||
headers["Origin"] = origin
|
||||
request = urllib.request.Request(
|
||||
self.url,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
response = urllib.request.urlopen(request, timeout=2)
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.loads(error.read())
|
||||
|
||||
def test_initialize_and_list_tools(self) -> None:
|
||||
status, initialized = self.request(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(initialized["result"]["protocolVersion"], "2025-03-26")
|
||||
status, listed = self.request(
|
||||
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
names = {tool["name"] for tool in listed["result"]["tools"]}
|
||||
self.assertIn("sync_since", names)
|
||||
self.assertIn("close_gate", names)
|
||||
|
||||
def test_authenticated_tool_call(self) -> None:
|
||||
status, response = self.request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_project",
|
||||
"arguments": {
|
||||
"project_id": "demo",
|
||||
"name": "Demo",
|
||||
"idempotency_key": "create-demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
token="bootstrap-secret-at-least-24",
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(response["result"]["isError"])
|
||||
self.assertEqual(response["result"]["structuredContent"]["project_id"], "demo")
|
||||
|
||||
def test_tool_call_requires_authentication(self) -> None:
|
||||
status, response = self.request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "sync_since", "arguments": {"project_id": "demo"}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
self.assertEqual(response["error"], "unauthorized")
|
||||
|
||||
def test_origin_allowlist(self) -> None:
|
||||
status, response = self.request(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
origin="https://evil.example",
|
||||
)
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual(response["error"], "origin_not_allowed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from project_bus.db import Database
|
||||
from project_bus.errors import AuthorizationError, ConflictError, ValidationError
|
||||
from project_bus.models import Principal
|
||||
from project_bus.service import ProjectBusService
|
||||
|
||||
|
||||
class ServiceTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.database_path = Path(self.temporary.name) / "bus.db"
|
||||
self.database = Database(self.database_path)
|
||||
self.service = ProjectBusService(self.database)
|
||||
self.service.initialize()
|
||||
self.system = Principal("system", "system", "System")
|
||||
self.service.create_project(
|
||||
self.system,
|
||||
project_id="sandbox",
|
||||
name="Sandbox",
|
||||
idempotency_key="create-sandbox",
|
||||
)
|
||||
self.principals: dict[str, Principal] = {}
|
||||
for actor_id, role in (
|
||||
("po", "PO"),
|
||||
("pm", "PM"),
|
||||
("pa", "PA"),
|
||||
("lead", "LEAD_ENGINEER"),
|
||||
("impl2", "IMPLEMENTER"),
|
||||
("reviewer", "REVIEWER"),
|
||||
("security", "SECURITY_REVIEWER"),
|
||||
):
|
||||
self.service.register_actor(
|
||||
self.system,
|
||||
project_id="sandbox",
|
||||
actor_id=actor_id,
|
||||
auth_subject=f"subject-{actor_id}",
|
||||
display_name=actor_id.title(),
|
||||
role=role,
|
||||
idempotency_key=f"register-{actor_id}",
|
||||
)
|
||||
self.principals[actor_id] = Principal(
|
||||
actor_id, f"subject-{actor_id}", actor_id.title()
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def issue(self, work_package_id: str = "WP-001") -> None:
|
||||
self.service.issue_work_package(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id=work_package_id,
|
||||
title="Build it",
|
||||
description="Implement the bounded feature.",
|
||||
acceptance_criteria=["Tests pass", "Evidence linked"],
|
||||
idempotency_key=f"issue-{work_package_id}",
|
||||
)
|
||||
|
||||
def submit_and_review(self, work_package_id: str = "WP-001") -> str:
|
||||
self.service.claim_work_package(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id=work_package_id,
|
||||
idempotency_key=f"claim-{work_package_id}",
|
||||
)
|
||||
self.service.submit_result(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id=work_package_id,
|
||||
summary="Implemented.",
|
||||
evidence=[{"type": "test", "value": "all green"}],
|
||||
idempotency_key=f"result-{work_package_id}",
|
||||
)
|
||||
requested = self.service.request_review(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
work_package_id=work_package_id,
|
||||
reviewer_id="reviewer",
|
||||
review_type="technical",
|
||||
idempotency_key=f"request-review-{work_package_id}",
|
||||
)
|
||||
self.service.submit_review(
|
||||
self.principals["reviewer"],
|
||||
project_id="sandbox",
|
||||
review_id=requested["review_id"],
|
||||
verdict="APPROVE",
|
||||
findings=[],
|
||||
summary="Approved independently.",
|
||||
idempotency_key=f"review-{work_package_id}",
|
||||
)
|
||||
return requested["review_id"]
|
||||
|
||||
def test_full_workflow_gate_and_baseline(self) -> None:
|
||||
self.issue()
|
||||
self.submit_and_review()
|
||||
linked = self.service.link_commit(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
repository="git.example/project-bus",
|
||||
commit_sha="0123456789abcdef",
|
||||
relation="implementation",
|
||||
idempotency_key="link-commit",
|
||||
)
|
||||
self.assertEqual(linked["event"]["event_type"], "commit.linked")
|
||||
gate = self.service.close_gate(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
outcome="ACCEPT",
|
||||
rationale="Criteria and review satisfied.",
|
||||
idempotency_key="close-gate",
|
||||
)
|
||||
self.assertEqual(gate["status"], "ACCEPTED")
|
||||
baseline = self.service.baseline_release(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
version="0.1.0",
|
||||
artifact_uri="https://artifacts.example/project-bus-0.1.0.tar.gz",
|
||||
digest="sha256:abc",
|
||||
included_work_packages=["WP-001"],
|
||||
idempotency_key="release-010",
|
||||
)
|
||||
self.assertEqual(baseline["version"], "0.1.0")
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.close_gate(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
outcome="HOLD",
|
||||
rationale="Cannot reopen a terminal gate.",
|
||||
idempotency_key="reopen-gate",
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.baseline_release(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
version="0.1.0",
|
||||
artifact_uri="https://artifacts.example/other.tar.gz",
|
||||
digest="sha256:different",
|
||||
included_work_packages=["WP-001"],
|
||||
idempotency_key="duplicate-release-version",
|
||||
)
|
||||
events = self.service.sync_since(
|
||||
self.principals["pa"], project_id="sandbox", cursor=0, limit=100
|
||||
)
|
||||
self.assertEqual(events["events"][-1]["event_type"], "baseline.released")
|
||||
self.assertFalse(events["has_more"])
|
||||
|
||||
def test_pa_cannot_record_decision_or_close_gate(self) -> None:
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.service.record_decision(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
decision_id="DEC-001",
|
||||
kind="ARCHITECTURE",
|
||||
title="Architecture",
|
||||
decision="Use option A.",
|
||||
rationale="Because.",
|
||||
work_package_id=None,
|
||||
idempotency_key="pa-decision",
|
||||
)
|
||||
self.issue()
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.service.close_gate(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
outcome="HOLD",
|
||||
rationale="Waiting.",
|
||||
idempotency_key="pa-gate",
|
||||
)
|
||||
|
||||
def test_only_po_can_record_product_decision(self) -> None:
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.service.record_decision(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
decision_id="DEC-PRODUCT-1",
|
||||
kind="PRODUCT",
|
||||
title="Product scope",
|
||||
decision="Add a feature.",
|
||||
rationale="Customer need.",
|
||||
work_package_id=None,
|
||||
idempotency_key="pm-product",
|
||||
)
|
||||
result = self.service.record_decision(
|
||||
self.principals["po"],
|
||||
project_id="sandbox",
|
||||
decision_id="DEC-PRODUCT-1",
|
||||
kind="PRODUCT",
|
||||
title="Product scope",
|
||||
decision="Add a feature.",
|
||||
rationale="Customer need.",
|
||||
work_package_id=None,
|
||||
idempotency_key="po-product",
|
||||
)
|
||||
self.assertEqual(result["kind"], "PRODUCT")
|
||||
|
||||
def test_reviewer_must_be_independent_and_have_review_role(self) -> None:
|
||||
self.issue()
|
||||
self.service.claim_work_package(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
idempotency_key="claim",
|
||||
)
|
||||
self.service.submit_result(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
summary="Done.",
|
||||
evidence=[],
|
||||
idempotency_key="result",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.service.request_review(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
reviewer_id="lead",
|
||||
review_type="technical",
|
||||
idempotency_key="self-review",
|
||||
)
|
||||
|
||||
def test_gate_accept_requires_approved_review(self) -> None:
|
||||
self.issue()
|
||||
self.service.claim_work_package(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
idempotency_key="claim",
|
||||
)
|
||||
self.service.submit_result(
|
||||
self.principals["lead"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
summary="Done.",
|
||||
evidence=[],
|
||||
idempotency_key="result",
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.close_gate(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
outcome="ACCEPT",
|
||||
rationale="Premature.",
|
||||
idempotency_key="premature-gate",
|
||||
)
|
||||
|
||||
def test_idempotent_replay_and_key_mismatch(self) -> None:
|
||||
first = self.service.issue_work_package(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
title="Build it",
|
||||
description="First request.",
|
||||
acceptance_criteria=["Pass"],
|
||||
idempotency_key="same-key",
|
||||
)
|
||||
second = self.service.issue_work_package(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
title="Build it",
|
||||
description="First request.",
|
||||
acceptance_criteria=["Pass"],
|
||||
idempotency_key="same-key",
|
||||
)
|
||||
self.assertFalse(first["idempotent_replay"])
|
||||
self.assertTrue(second["idempotent_replay"])
|
||||
self.assertEqual(first["event"]["event_id"], second["event"]["event_id"])
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.issue_work_package(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-OTHER",
|
||||
title="Different",
|
||||
description="Different request.",
|
||||
acceptance_criteria=["Pass"],
|
||||
idempotency_key="same-key",
|
||||
)
|
||||
|
||||
def test_concurrent_claim_has_single_winner(self) -> None:
|
||||
self.issue()
|
||||
barrier = threading.Barrier(2)
|
||||
outcomes: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def claim(actor_id: str) -> None:
|
||||
barrier.wait()
|
||||
try:
|
||||
self.service.claim_work_package(
|
||||
self.principals[actor_id],
|
||||
project_id="sandbox",
|
||||
work_package_id="WP-001",
|
||||
idempotency_key=f"claim-{actor_id}",
|
||||
)
|
||||
value = "won"
|
||||
except ConflictError:
|
||||
value = "lost"
|
||||
with lock:
|
||||
outcomes.append(value)
|
||||
|
||||
first = threading.Thread(target=claim, args=("lead",))
|
||||
second = threading.Thread(target=claim, args=("impl2",))
|
||||
first.start()
|
||||
second.start()
|
||||
first.join()
|
||||
second.join()
|
||||
self.assertCountEqual(outcomes, ["won", "lost"])
|
||||
|
||||
def test_cursor_pagination_has_no_duplicates(self) -> None:
|
||||
self.issue("WP-001")
|
||||
self.issue("WP-002")
|
||||
first = self.service.sync_since(
|
||||
self.principals["pa"], project_id="sandbox", cursor=0, limit=3
|
||||
)
|
||||
second = self.service.sync_since(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
cursor=first["next_cursor"],
|
||||
limit=100,
|
||||
)
|
||||
first_ids = {event["event_id"] for event in first["events"]}
|
||||
second_ids = {event["event_id"] for event in second["events"]}
|
||||
self.assertFalse(first_ids & second_ids)
|
||||
self.assertTrue(first["has_more"])
|
||||
|
||||
def test_work_package_ids_are_project_scoped_and_reads_are_isolated(self) -> None:
|
||||
self.issue("WP-001")
|
||||
self.service.create_project(
|
||||
self.system,
|
||||
project_id="other",
|
||||
name="Other",
|
||||
idempotency_key="create-other",
|
||||
)
|
||||
self.service.register_actor(
|
||||
self.system,
|
||||
project_id="other",
|
||||
actor_id="pm",
|
||||
auth_subject="subject-pm",
|
||||
display_name="Pm",
|
||||
role="PM",
|
||||
idempotency_key="other-register-pm",
|
||||
)
|
||||
self.service.issue_work_package(
|
||||
self.principals["pm"],
|
||||
project_id="other",
|
||||
work_package_id="WP-001",
|
||||
title="Same local identifier",
|
||||
description="Different project.",
|
||||
acceptance_criteria=["Isolated"],
|
||||
idempotency_key="other-issue-wp",
|
||||
)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.service.get_work_package(
|
||||
self.principals["pa"],
|
||||
project_id="other",
|
||||
work_package_id="WP-001",
|
||||
)
|
||||
|
||||
def test_event_log_rejects_update_and_delete(self) -> None:
|
||||
self.issue()
|
||||
with self.database.transaction(write=True) as connection:
|
||||
event_id = connection.execute(
|
||||
"SELECT event_id FROM event_log ORDER BY cursor LIMIT 1"
|
||||
).fetchone()["event_id"]
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute(
|
||||
"UPDATE event_log SET event_type = 'tampered' WHERE event_id = ?",
|
||||
(event_id,),
|
||||
)
|
||||
with self.database.transaction(write=True) as connection:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("DELETE FROM event_log")
|
||||
|
||||
def test_baseline_rejects_unaccepted_work_package(self) -> None:
|
||||
self.issue()
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.baseline_release(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
version="bad",
|
||||
artifact_uri="artifact://bad",
|
||||
digest="sha256:bad",
|
||||
included_work_packages=["WP-001"],
|
||||
idempotency_key="bad-baseline",
|
||||
)
|
||||
|
||||
def test_escalation_can_be_raised_by_pa_but_only_resolved_by_pm(self) -> None:
|
||||
escalation = self.service.raise_escalation(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
severity="HIGH",
|
||||
title="Decision needed",
|
||||
description="Tolerance exceeded.",
|
||||
target_role="PM",
|
||||
idempotency_key="raise-escalation",
|
||||
)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.service.resolve_escalation(
|
||||
self.principals["pa"],
|
||||
project_id="sandbox",
|
||||
escalation_id=escalation["escalation_id"],
|
||||
resolution="Resolved by PA.",
|
||||
idempotency_key="pa-resolve",
|
||||
)
|
||||
resolved = self.service.resolve_escalation(
|
||||
self.principals["pm"],
|
||||
project_id="sandbox",
|
||||
escalation_id=escalation["escalation_id"],
|
||||
resolution="PM supplied the required decision.",
|
||||
idempotency_key="pm-resolve",
|
||||
)
|
||||
self.assertEqual(resolved["status"], "RESOLVED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user