gabriel / musehub public
test_mcp_write_tools.py python
2,089 lines 81.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Section 15 — MCP Write Tools: 7-layer test suite.
2
3 Covers ``musehub/mcp/write_tools/`` — issues, proposals, releases, repos, labels.
4
5 Write tools under test:
6 execute_create_issue, execute_update_issue, execute_create_issue_comment
7 execute_create_proposal, execute_merge_proposal, execute_create_proposal_review,
8 execute_create_proposal_comment
9 execute_create_release
10 execute_create_repo
11 execute_create_label, execute_update_label, execute_delete_label
12
13 Bug fixed during intelligence gathering:
14 - _proposal_data referenced the wrong variable name — NameError
15 that would crash every call to execute_create_proposal / execute_merge_proposal.
16
17 Seven layers:
18
19 Layer 1 Unit:
20 - MUSEHUB_WRITE_TOOL_NAMES contains all expected write tool names
21 - execute_create_proposal_review: invalid event → error_code=invalid_mode
22 - execute_create_proposal: same from/to branch → error immediately
23 - _issue_data serialises IssueResponse to correct dict keys
24
25 Layer 2 Integration:
26 - execute_create_issue: happy path, unknown repo, with labels
27 - execute_update_issue: title change, close state, unknown repo
28 - execute_create_issue_comment: happy path, unknown issue
29 - execute_create_proposal: happy path, unknown repo, same-branch guard
30 - execute_merge_proposal: happy path, unknown proposal
31 - execute_create_proposal_review: approve / request_changes
32 - execute_create_release: happy path, duplicate tag error
33 - execute_create_repo: happy path returns repo_id + slug
34 - execute_create_label: happy path, duplicate name error
35
36 Layer 3 E2E (HTTP tools/call):
37 - Anonymous calls to every write tool → isError=True (auth gate)
38 - Authenticated write (mocked _extract_auth): create_issue succeeds
39 - Authenticated write (mocked _extract_auth): create_repo succeeds
40
41 Layer 4 Stress:
42 - 20 sequential issues → sequential numbers 1..20
43
44 Layer 5 Data Integrity:
45 - create_issue entity retrievable via execute_list_issues
46 - create_proposal entity retrievable via execute_list_proposals
47 - create_release tag persisted
48 - update_issue close: state persisted as "closed"
49
50 Layer 6 Security:
51 - All write tools in MUSEHUB_WRITE_TOOL_NAMES → auth gate in dispatcher
52 - execute_create_proposal same branches → error
53 - Unauthenticated HTTP call to write tool → isError=True
54
55 Layer 7 Performance:
56 - 10 sequential execute_create_issue under 500 ms
57 - 5 sequential execute_create_repo under 1000 ms
58 """
59 from __future__ import annotations
60
61 import json
62 import time
63 import uuid
64 from datetime import datetime, timezone
65 from unittest.mock import AsyncMock, patch
66
67 import pytest
68 import pytest_asyncio
69 from httpx import AsyncClient, ASGITransport
70 from sqlalchemy.ext.asyncio import AsyncSession
71
72 from musehub.db import musehub_models as db
73 from musehub.main import app
74 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOL_NAMES
75 from musehub.types.json_types import JSONObject, StrDict
76 from musehub.mcp.write_tools.issues import (
77 _issue_data,
78 execute_close_issue,
79 execute_create_issue,
80 execute_create_issue_comment,
81 execute_reopen_issue,
82 execute_assign_issue,
83 execute_update_issue_labels,
84 execute_remove_issue_label,
85 execute_update_issue,
86 )
87 from musehub.mcp.write_tools.proposals import (
88 execute_create_proposal,
89 execute_create_proposal_comment,
90 execute_merge_proposal,
91 execute_create_proposal_review,
92 execute_list_proposal_comments,
93 execute_request_proposal_reviewers,
94 execute_remove_proposal_reviewer,
95 execute_list_proposal_reviews,
96 )
97 from musehub.mcp.write_tools.releases import (
98 execute_create_release,
99 execute_attach_release_asset,
100 execute_delete_release_asset,
101 )
102 from musehub.mcp.write_tools.repos import (
103 execute_create_repo,
104 execute_delete_repo,
105 execute_update_repo,
106 execute_transfer_repo_ownership,
107 )
108 from musehub.mcp.write_tools.labels import (
109 execute_create_label,
110 execute_delete_label,
111 execute_update_label,
112 )
113 from musehub.mcp.write_tools.collaborators import (
114 execute_invite_collaborator,
115 execute_list_collaborators,
116 execute_remove_collaborator,
117 execute_update_collaborator_permission,
118 )
119 from musehub.mcp.write_tools.webhooks import (
120 execute_create_webhook,
121 execute_delete_webhook,
122 execute_list_webhooks,
123 )
124 from musehub.mcp.write_tools.issues import execute_delete_issue_comment
125 from musehub.services.musehub_mcp_executor import (
126 execute_list_issues,
127 execute_list_labels,
128 execute_list_proposals,
129 )
130
131
132 # ── Fixtures ──────────────────────────────────────────────────────────────────
133
134
135 @pytest.fixture
136 def anyio_backend() -> str:
137 return "asyncio"
138
139
140 @pytest_asyncio.fixture
141 async def http_client(db_session: AsyncSession) -> AsyncClient:
142 async with AsyncClient(
143 transport=ASGITransport(app=app),
144 base_url="http://localhost",
145 ) as c:
146 yield c
147
148
149 # ── Helpers ───────────────────────────────────────────────────────────────────
150
151
152 def _uid() -> str:
153 return str(uuid.uuid4())
154
155
156 def _slug() -> str:
157 return f"repo-{uuid.uuid4().hex[:8]}"
158
159
160 async def _repo(
161 session: AsyncSession,
162 slug: str | None = None,
163 visibility: str = "public",
164 owner: str = "alice",
165 ) -> db.MusehubRepo:
166 name = slug or _slug()
167 r = db.MusehubRepo(
168 name=name,
169 owner=owner,
170 slug=name,
171 visibility=visibility,
172 owner_user_id="uid-alice",
173 )
174 session.add(r)
175 await session.flush()
176 await session.refresh(r)
177 return r
178
179
180 async def _commit_and_branch(
181 session: AsyncSession,
182 repo_id: str,
183 branch: str = "main",
184 ) -> db.MusehubCommit:
185 c = db.MusehubCommit(
186 commit_id=uuid.uuid4().hex[:16],
187 repo_id=repo_id,
188 branch=branch,
189 parent_ids=[],
190 message="init",
191 author="alice",
192 timestamp=datetime.now(tz=timezone.utc),
193 )
194 b = db.MusehubBranch(
195 repo_id=repo_id,
196 name=branch,
197 head_commit_id=c.commit_id,
198 )
199 session.add(c)
200 session.add(b)
201 await session.flush()
202 return c
203
204
205 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
206 return {
207 "jsonrpc": "2.0",
208 "id": 1,
209 "method": "tools/call",
210 "params": {"name": name, "arguments": arguments},
211 }
212
213
214 def _unwrap_tool_text(text: str) -> str:
215 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
216 text = text.strip()
217 if text.startswith("<musehub_tool_result>"):
218 text = text[len("<musehub_tool_result>"):].strip()
219 if text.endswith("</musehub_tool_result>"):
220 text = text[: -len("</musehub_tool_result>")].strip()
221 return text
222
223
224 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
225
226
227 class TestUnitWriteToolCatalogue:
228 def test_write_tool_names_non_empty(self) -> None:
229 assert len(MUSEHUB_WRITE_TOOL_NAMES) > 0
230
231 def test_expected_tools_in_write_set(self) -> None:
232 expected = {
233 "musehub_create_issue",
234 "musehub_create_repo",
235 "musehub_create_proposal",
236 "musehub_merge_proposal",
237 "musehub_create_release",
238 "musehub_create_label",
239 "musehub_update_label",
240 "musehub_delete_label",
241 }
242 missing = expected - MUSEHUB_WRITE_TOOL_NAMES
243 assert not missing, f"Tools missing from write set: {missing}"
244
245
246 class TestUnitEarlyValidation:
247 async def test_submit_review_invalid_event(self, db_session: AsyncSession) -> None:
248 result = await execute_create_proposal_review(
249 repo_id="any", proposal_id="any", event="lgtm", reviewer="alice"
250 )
251 assert result.ok is False
252 assert result.error_code == "invalid_args"
253 assert "approve" in (result.error_message or "")
254
255 async def test_create_proposal_same_branches(self, db_session: AsyncSession) -> None:
256 result = await execute_create_proposal(
257 repo_id="any", title="T", from_branch="main", to_branch="main", actor="alice"
258 )
259 assert result.ok is False
260 assert "different" in (result.error_message or "").lower()
261
262
263 class TestUnitIssueData:
264 def test_issue_data_produces_correct_keys(self, db_session: AsyncSession) -> None:
265 from musehub.models.musehub import IssueResponse
266
267 issue = IssueResponse(
268 issue_id="iid-1",
269 number=7,
270 title="Test issue",
271 body="Body",
272 state="open",
273 labels=["bug"],
274 author="alice",
275 created_at=datetime.now(tz=timezone.utc),
276 )
277 d = _issue_data(issue)
278 for key in ("issue_id", "number", "title", "body", "state", "labels", "author"):
279 assert key in d, f"Missing key: {key}"
280 assert d["number"] == 7
281 assert d["labels"] == ["bug"]
282
283
284 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
285
286
287 class TestIntegrationCreateIssue:
288 async def test_happy_path(self, db_session: AsyncSession) -> None:
289 r = await _repo(db_session)
290 await db_session.commit()
291 result = await execute_create_issue(
292 repo_id=r.repo_id, title="Bass too loud", body="Track 4, bar 12.", actor="alice"
293 )
294 assert result.ok is True
295 assert "issue_id" in result.data
296 assert result.data["number"] >= 1
297 assert result.data["title"] == "Bass too loud"
298
299 async def test_any_user_can_create_issue_on_public_repo(self, db_session: AsyncSession) -> None:
300 r = await _repo(db_session, visibility="public")
301 await db_session.commit()
302 result = await execute_create_issue(
303 repo_id=r.repo_id, title="Public comment", actor="carol"
304 )
305 assert result.ok is True
306
307 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
308 r = await _repo(db_session, visibility="private")
309 await db_session.commit()
310 result = await execute_create_issue(
311 repo_id=r.repo_id, title="Private issue", actor="carol"
312 )
313 assert result.ok is False
314 assert result.error_code == "forbidden"
315
316 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
317 r = await _repo(db_session)
318 await db_session.commit()
319 result = await execute_create_issue(
320 repo_id=r.repo_id, title="T", actor=""
321 )
322 assert result.ok is False
323 assert result.error_code == "forbidden"
324
325 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
326 result = await execute_create_issue(
327 repo_id="ghost-repo", title="T", actor="alice"
328 )
329 assert result.ok is False
330 assert result.error_code == "repo_not_found"
331
332 async def test_with_labels(self, db_session: AsyncSession) -> None:
333 r = await _repo(db_session)
334 await db_session.commit()
335 result = await execute_create_issue(
336 repo_id=r.repo_id,
337 title="Harmony conflict",
338 labels=["harmony", "critical"],
339 actor="alice",
340 )
341 assert result.ok is True
342 assert set(result.data["labels"]) == {"harmony", "critical"}
343
344
345 class TestIntegrationUpdateIssue:
346 async def test_update_title(self, db_session: AsyncSession) -> None:
347 r = await _repo(db_session)
348 await db_session.commit()
349 created = await execute_create_issue(
350 repo_id=r.repo_id, title="Old title", actor="alice"
351 )
352 num = created.data["number"]
353
354 result = await execute_update_issue(
355 repo_id=r.repo_id, issue_number=num, title="New title", actor="alice"
356 )
357 assert result.ok is True
358 assert result.data["title"] == "New title"
359
360 async def test_close_issue(self, db_session: AsyncSession) -> None:
361 r = await _repo(db_session)
362 await db_session.commit()
363 created = await execute_create_issue(
364 repo_id=r.repo_id, title="To close", actor="alice"
365 )
366 num = created.data["number"]
367
368 result = await execute_update_issue(
369 repo_id=r.repo_id, issue_number=num, state="closed", actor="alice"
370 )
371 assert result.ok is True
372 assert result.data["state"] == "closed"
373
374 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
375 result = await execute_update_issue(
376 repo_id="ghost-repo", issue_number=1, title="x", actor="alice"
377 )
378 assert result.ok is False
379 assert result.error_code == "repo_not_found"
380
381 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
382 r = await _repo(db_session)
383 await db_session.commit()
384 result = await execute_update_issue(
385 repo_id=r.repo_id, issue_number=1, title="x", actor=""
386 )
387 assert result.ok is False
388 assert result.error_code == "forbidden"
389
390 async def test_forbidden_non_collaborator(self, db_session: AsyncSession) -> None:
391 r = await _repo(db_session)
392 await db_session.commit()
393 created = await execute_create_issue(
394 repo_id=r.repo_id, title="Some issue", actor="alice"
395 )
396 num = created.data["number"]
397 result = await execute_update_issue(
398 repo_id=r.repo_id, issue_number=num, title="x", actor="carol"
399 )
400 assert result.ok is False
401 assert result.error_code == "forbidden"
402
403
404 class TestIntegrationCreateIssueComment:
405 async def test_happy_path(self, db_session: AsyncSession) -> None:
406 r = await _repo(db_session, visibility="public")
407 await db_session.commit()
408 issue = await execute_create_issue(
409 repo_id=r.repo_id, title="I", actor="alice"
410 )
411 num = issue.data["number"]
412
413 # Any authenticated user can comment on a public repo
414 result = await execute_create_issue_comment(
415 repo_id=r.repo_id, issue_number=num, body="LGTM!", actor="bob"
416 )
417 assert result.ok is True
418 assert "comment_id" in result.data
419 assert result.data["body"] == "LGTM!"
420
421 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
422 r = await _repo(db_session)
423 await db_session.commit()
424 result = await execute_create_issue_comment(
425 repo_id=r.repo_id, issue_number=1, body="x", actor=""
426 )
427 assert result.ok is False
428 assert result.error_code == "forbidden"
429
430 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
431 r = await _repo(db_session, visibility="private")
432 await db_session.commit()
433 issue = await execute_create_issue(repo_id=r.repo_id, title="I", actor="alice")
434 num = issue.data["number"]
435 result = await execute_create_issue_comment(
436 repo_id=r.repo_id, issue_number=num, body="x", actor="carol"
437 )
438 assert result.ok is False
439 assert result.error_code == "forbidden"
440
441 async def test_unknown_issue(self, db_session: AsyncSession) -> None:
442 r = await _repo(db_session)
443 await db_session.commit()
444 result = await execute_create_issue_comment(
445 repo_id=r.repo_id, issue_number=999, body="x", actor="alice"
446 )
447 assert result.ok is False
448 assert result.error_code == "issue_not_found"
449
450
451 class TestIntegrationCreateProposal:
452 async def test_happy_path(self, db_session: AsyncSession) -> None:
453 r = await _repo(db_session)
454 await _commit_and_branch(db_session, r.repo_id, "main")
455 await _commit_and_branch(db_session, r.repo_id, "feat-harmony")
456 await db_session.commit()
457
458 result = await execute_create_proposal(
459 repo_id=r.repo_id,
460 title="Add harmony layer",
461 from_branch="feat-harmony",
462 to_branch="main",
463 body="Adds new harmonic dimension.",
464 actor="alice",
465 )
466 assert result.ok is True
467 assert "proposal_id" in result.data
468 assert result.data["state"] == "open"
469
470 async def test_any_user_can_create_proposal_on_public_repo(self, db_session: AsyncSession) -> None:
471 r = await _repo(db_session, visibility="public")
472 await _commit_and_branch(db_session, r.repo_id, "main")
473 await _commit_and_branch(db_session, r.repo_id, "feat-pub")
474 await db_session.commit()
475 result = await execute_create_proposal(
476 repo_id=r.repo_id, title="Public PR", from_branch="feat-pub", to_branch="main", actor="carol"
477 )
478 assert result.ok is True
479
480 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
481 r = await _repo(db_session, visibility="private")
482 await _commit_and_branch(db_session, r.repo_id, "main")
483 await _commit_and_branch(db_session, r.repo_id, "feat-prv")
484 await db_session.commit()
485 result = await execute_create_proposal(
486 repo_id=r.repo_id, title="Private PR", from_branch="feat-prv", to_branch="main", actor="carol"
487 )
488 assert result.ok is False
489 assert result.error_code == "forbidden"
490
491 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
492 r = await _repo(db_session)
493 await _commit_and_branch(db_session, r.repo_id, "main")
494 await _commit_and_branch(db_session, r.repo_id, "feat-noauth")
495 await db_session.commit()
496 result = await execute_create_proposal(
497 repo_id=r.repo_id, title="T", from_branch="feat-noauth", to_branch="main", actor=""
498 )
499 assert result.ok is False
500 assert result.error_code == "forbidden"
501
502 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
503 result = await execute_create_proposal(
504 repo_id="ghost-proposal", title="T", from_branch="feat", to_branch="main", actor="alice"
505 )
506 assert result.ok is False
507 assert result.error_code == "repo_not_found"
508
509
510 class TestProposalMergeAccessGuard:
511 """Ownership checks on execute_merge_proposal."""
512
513 async def test_merge_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
514 """Non-owner actor receives error_code='forbidden' from execute_merge_proposal."""
515 r = await _repo(db_session, owner="alice")
516 await _commit_and_branch(db_session, r.repo_id, "main")
517 await _commit_and_branch(db_session, r.repo_id, "feat-x")
518 await db_session.commit()
519
520 proposal_result = await execute_create_proposal(
521 repo_id=r.repo_id,
522 title="Non-owner merge attempt",
523 from_branch="feat-x",
524 to_branch="main",
525 actor="alice",
526 )
527 assert proposal_result.ok is True
528 proposal_id = proposal_result.data["proposal_id"]
529
530 # "carol" is not the owner and has no collaborator entry.
531 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="carol")
532 assert result.ok is False
533 assert result.error_code == "forbidden"
534
535 async def test_merge_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
536 """Empty actor string (unauthenticated) receives error_code='forbidden'."""
537 r = await _repo(db_session, owner="alice")
538 await _commit_and_branch(db_session, r.repo_id, "main")
539 await _commit_and_branch(db_session, r.repo_id, "feat-anon")
540 await db_session.commit()
541
542 proposal_result = await execute_create_proposal(
543 repo_id=r.repo_id,
544 title="Unauthenticated merge attempt",
545 from_branch="feat-anon",
546 to_branch="main",
547 actor="alice",
548 )
549 assert proposal_result.ok is True
550 proposal_id = proposal_result.data["proposal_id"]
551
552 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="")
553 assert result.ok is False
554 assert result.error_code == "forbidden"
555
556
557 class TestIntegrationMergeProposal:
558 async def test_merge_open_proposal(self, db_session: AsyncSession) -> None:
559 r = await _repo(db_session)
560 await _commit_and_branch(db_session, r.repo_id, "main")
561 await _commit_and_branch(db_session, r.repo_id, "feat-bass")
562 await db_session.commit()
563
564 proposal_result = await execute_create_proposal(
565 repo_id=r.repo_id,
566 title="Add bass line",
567 from_branch="feat-bass",
568 to_branch="main",
569 actor="alice",
570 )
571 assert proposal_result.ok is True
572 proposal_id = proposal_result.data["proposal_id"]
573
574 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="alice")
575 assert result.ok is True
576 assert result.data["state"] == "merged"
577
578 async def test_merge_unknown_proposal(self, db_session: AsyncSession) -> None:
579 r = await _repo(db_session)
580 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id="ghost-proposal-id", actor="alice")
581 assert result.ok is False
582
583
584 class TestIntegrationSubmitReview:
585 async def test_approve(self, db_session: AsyncSession) -> None:
586 r = await _repo(db_session)
587 await _commit_and_branch(db_session, r.repo_id, "main")
588 await _commit_and_branch(db_session, r.repo_id, "feat-review")
589 await db_session.commit()
590
591 proposal_result = await execute_create_proposal(
592 repo_id=r.repo_id,
593 title="Review test",
594 from_branch="feat-review",
595 to_branch="main",
596 actor="alice",
597 )
598 proposal_id = proposal_result.data["proposal_id"]
599
600 result = await execute_create_proposal_review(
601 repo_id=r.repo_id, proposal_id=proposal_id, event="approve", reviewer="bob"
602 )
603 assert result.ok is True
604 assert result.data["state"] == "approved"
605
606 async def test_request_changes(self, db_session: AsyncSession) -> None:
607 r = await _repo(db_session)
608 await _commit_and_branch(db_session, r.repo_id, "main")
609 await _commit_and_branch(db_session, r.repo_id, "feat-chg")
610 await db_session.commit()
611
612 proposal_result = await execute_create_proposal(
613 repo_id=r.repo_id,
614 title="Changes test",
615 from_branch="feat-chg",
616 to_branch="main",
617 actor="alice",
618 )
619 proposal_id = proposal_result.data["proposal_id"]
620
621 result = await execute_create_proposal_review(
622 repo_id=r.repo_id, proposal_id=proposal_id, event="request_changes", reviewer="carol"
623 )
624 assert result.ok is True
625 assert result.data["state"] == "changes_requested"
626
627 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
628 """Empty reviewer (unauthenticated) must be rejected before any DB access."""
629 r = await _repo(db_session)
630 await db_session.commit()
631 result = await execute_create_proposal_review(
632 repo_id=r.repo_id, proposal_id="any-id", event="approve", reviewer=""
633 )
634 assert result.ok is False
635 assert result.error_code == "forbidden"
636
637 async def test_forbidden_on_private_repo_for_non_member(self, db_session: AsyncSession) -> None:
638 """Non-member carol cannot review proposals on a private repo."""
639 r = await _repo(db_session, visibility="private")
640 await _commit_and_branch(db_session, r.repo_id, "main")
641 await _commit_and_branch(db_session, r.repo_id, "feat-prv")
642 await db_session.commit()
643
644 proposal_result = await execute_create_proposal(
645 repo_id=r.repo_id,
646 title="Private review test",
647 from_branch="feat-prv",
648 to_branch="main",
649 actor="alice",
650 )
651 proposal_id = proposal_result.data["proposal_id"]
652
653 result = await execute_create_proposal_review(
654 repo_id=r.repo_id, proposal_id=proposal_id, event="approve", reviewer="carol"
655 )
656 assert result.ok is False
657 assert result.error_code == "forbidden"
658
659 async def test_any_user_can_review_public_repo(self, db_session: AsyncSession) -> None:
660 """Any authenticated user may submit a review on a public repo."""
661 r = await _repo(db_session, visibility="public")
662 await _commit_and_branch(db_session, r.repo_id, "main")
663 await _commit_and_branch(db_session, r.repo_id, "feat-pub")
664 await db_session.commit()
665
666 proposal_result = await execute_create_proposal(
667 repo_id=r.repo_id,
668 title="Public review test",
669 from_branch="feat-pub",
670 to_branch="main",
671 actor="alice",
672 )
673 proposal_id = proposal_result.data["proposal_id"]
674
675 result = await execute_create_proposal_review(
676 repo_id=r.repo_id, proposal_id=proposal_id, event="comment", reviewer="dave"
677 )
678 assert result.ok is True
679 assert result.data["state"] == "pending"
680
681
682 class TestIntegrationCreateRelease:
683 async def test_happy_path(self, db_session: AsyncSession) -> None:
684 r = await _repo(db_session)
685 await db_session.commit()
686 result = await execute_create_release(
687 repo_id=r.repo_id,
688 tag="v1.0.0",
689 title="First Release",
690 body="Initial stable release.",
691 channel="stable",
692 actor="alice",
693 )
694 assert result.ok is True
695 assert result.data["tag"] == "v1.0.0"
696 assert result.data["channel"] == "stable"
697 assert "release_id" in result.data
698
699 async def test_duplicate_tag_error(self, db_session: AsyncSession) -> None:
700 r = await _repo(db_session)
701 await db_session.commit()
702 await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
703 result = await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
704 assert result.ok is False
705
706 async def test_beta_channel(self, db_session: AsyncSession) -> None:
707 r = await _repo(db_session)
708 await db_session.commit()
709 result = await execute_create_release(
710 repo_id=r.repo_id, tag="v2.0.0-beta.1", channel="beta", actor="alice"
711 )
712 assert result.ok is True
713 assert result.data["channel"] == "beta"
714
715
716 class TestIntegrationCreateRepo:
717 async def test_happy_path(self, db_session: AsyncSession) -> None:
718 result = await execute_create_repo(
719 name="jazz-standards",
720 owner="alice",
721 owner_user_id="uid-alice",
722 description="My jazz collection",
723 visibility="public",
724 )
725 assert result.ok is True
726 assert "repo_id" in result.data
727 assert result.data["owner"] == "alice"
728 assert "jazz" in result.data["slug"]
729
730 async def test_private_visibility(self, db_session: AsyncSession) -> None:
731 result = await execute_create_repo(
732 name="private-session",
733 owner="alice",
734 owner_user_id="uid-alice",
735 visibility="private",
736 )
737 assert result.ok is True
738 assert result.data["visibility"] == "private"
739
740 async def test_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
741 """Empty owner_user_id (unauthenticated caller) must be rejected."""
742 result = await execute_create_repo(
743 name="hacked-repo",
744 owner="",
745 owner_user_id="",
746 )
747 assert result.ok is False
748 assert result.error_code == "forbidden"
749
750
751 class TestIntegrationCreateLabel:
752 async def test_happy_path(self, db_session: AsyncSession) -> None:
753 r = await _repo(db_session)
754 await db_session.commit()
755 result = await execute_create_label(
756 repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice"
757 )
758 assert result.ok is True
759 assert "label_id" in result.data
760 assert result.data["name"] == "bug"
761 assert result.data["color"] == "#d73a4a"
762
763 async def test_duplicate_name_rejected(self, db_session: AsyncSession) -> None:
764 r = await _repo(db_session)
765 await db_session.commit()
766 await execute_create_label(repo_id=r.repo_id, name="dup-label", color="#aabbcc", actor="alice")
767 result = await execute_create_label(
768 repo_id=r.repo_id, name="dup-label", color="#112233", actor="alice"
769 )
770 assert result.ok is False
771 assert "already exists" in (result.error_message or "").lower()
772
773 async def test_invalid_color_rejected(self, db_session: AsyncSession) -> None:
774 r = await _repo(db_session)
775 await db_session.commit()
776 result = await execute_create_label(
777 repo_id=r.repo_id, name="bad-color", color="d73a4a" # missing #
778 )
779 assert result.ok is False
780 assert result.error_code == "invalid_args"
781
782 async def test_empty_name_rejected(self, db_session: AsyncSession) -> None:
783 r = await _repo(db_session)
784 await db_session.commit()
785 result = await execute_create_label(repo_id=r.repo_id, name=" ", color="#d73a4a")
786 assert result.ok is False
787 assert result.error_code == "invalid_args"
788
789 async def test_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
790 result = await execute_create_label(
791 repo_id="00000000-0000-0000-0000-000000000000",
792 name="bug",
793 color="#d73a4a",
794 )
795 assert result.ok is False
796 assert result.error_code == "repo_not_found"
797
798
799 class TestIntegrationListLabels:
800 async def test_returns_all_labels(self, db_session: AsyncSession) -> None:
801 r = await _repo(db_session)
802 await db_session.commit()
803 await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
804 await execute_create_label(repo_id=r.repo_id, name="enhancement", color="#a2eeef", actor="alice")
805 result = await execute_list_labels(r.repo_id)
806 assert result.ok is True
807 assert result.data["total"] == 2
808 names = {lbl["name"] for lbl in result.data["labels"]}
809 assert names == {"bug", "enhancement"}
810
811 async def test_empty_repo_returns_empty_list(self, db_session: AsyncSession) -> None:
812 r = await _repo(db_session)
813 await db_session.commit()
814 result = await execute_list_labels(r.repo_id)
815 assert result.ok is True
816 assert result.data["total"] == 0
817 assert result.data["labels"] == []
818
819 async def test_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
820 result = await execute_list_labels("00000000-0000-0000-0000-000000000000")
821 assert result.ok is False
822 assert result.error_code == "repo_not_found"
823
824
825 class TestIntegrationUpdateLabel:
826 async def test_rename_label(self, db_session: AsyncSession) -> None:
827 r = await _repo(db_session)
828 await db_session.commit()
829 created = await execute_create_label(repo_id=r.repo_id, name="old-name", color="#d73a4a", actor="alice")
830 label_id: str = created.data["label_id"]
831 result = await execute_update_label(
832 repo_id=r.repo_id, label_id=label_id, name="new-name", actor="alice"
833 )
834 assert result.ok is True
835 assert result.data["name"] == "new-name"
836 assert result.data["color"] == "#d73a4a" # unchanged
837
838 async def test_change_color(self, db_session: AsyncSession) -> None:
839 r = await _repo(db_session)
840 await db_session.commit()
841 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
842 label_id: str = created.data["label_id"]
843 result = await execute_update_label(
844 repo_id=r.repo_id, label_id=label_id, color="#b60205", actor="alice"
845 )
846 assert result.ok is True
847 assert result.data["color"] == "#b60205"
848 assert result.data["name"] == "bug" # unchanged
849
850 async def test_no_fields_returns_error(self, db_session: AsyncSession) -> None:
851 r = await _repo(db_session)
852 await db_session.commit()
853 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
854 result = await execute_update_label(
855 repo_id=r.repo_id, label_id=created.data["label_id"]
856 )
857 assert result.ok is False
858 assert result.error_code == "invalid_args"
859
860 async def test_rename_conflict_rejected(self, db_session: AsyncSession) -> None:
861 r = await _repo(db_session)
862 await db_session.commit()
863 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
864 await execute_create_label(repo_id=r.repo_id, name="enhancement", color="#a2eeef", actor="alice")
865 result = await execute_update_label(
866 repo_id=r.repo_id, label_id=created.data["label_id"], name="enhancement", actor="alice"
867 )
868 assert result.ok is False
869 assert result.error_code == "already_exists"
870
871 async def test_not_found_returns_error(self, db_session: AsyncSession) -> None:
872 r = await _repo(db_session)
873 await db_session.commit()
874 result = await execute_update_label(
875 repo_id=r.repo_id,
876 label_id="00000000-0000-0000-0000-000000000000",
877 name="new-name",
878 actor="alice",
879 )
880 assert result.ok is False
881 assert result.error_code == "not_found"
882
883 async def test_invalid_color_rejected(self, db_session: AsyncSession) -> None:
884 r = await _repo(db_session)
885 await db_session.commit()
886 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
887 result = await execute_update_label(
888 repo_id=r.repo_id, label_id=created.data["label_id"], color="b60205" # missing #
889 )
890 assert result.ok is False
891 assert result.error_code == "invalid_args"
892
893
894 class TestIntegrationDeleteLabel:
895 async def test_deletes_label(self, db_session: AsyncSession) -> None:
896 r = await _repo(db_session)
897 await db_session.commit()
898 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
899 label_id: str = created.data["label_id"]
900 result = await execute_delete_label(repo_id=r.repo_id, label_id=label_id, actor="alice")
901 assert result.ok is True
902 assert result.data["deleted"] is True
903 assert result.data["name"] == "bug"
904 # Confirm it no longer exists.
905 listed = await execute_list_labels(r.repo_id)
906 assert listed.data["total"] == 0
907
908 async def test_not_found_returns_error(self, db_session: AsyncSession) -> None:
909 r = await _repo(db_session)
910 await db_session.commit()
911 result = await execute_delete_label(
912 repo_id=r.repo_id,
913 label_id="00000000-0000-0000-0000-000000000000",
914 actor="alice",
915 )
916 assert result.ok is False
917 assert result.error_code == "not_found"
918
919
920 class TestLabelWriteAccessGuard:
921 """Non-owners must be rejected when managing labels."""
922
923 async def test_create_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
924 r = await _repo(db_session)
925 await db_session.commit()
926 result = await execute_create_label(
927 repo_id=r.repo_id, name="bug", color="#d73a4a", actor="eve"
928 )
929 assert result.ok is False
930 assert result.error_code == "forbidden"
931
932 async def test_update_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
933 r = await _repo(db_session)
934 await db_session.commit()
935 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
936 result = await execute_update_label(
937 repo_id=r.repo_id, label_id=created.data["label_id"], name="hacked", actor="eve"
938 )
939 assert result.ok is False
940 assert result.error_code == "forbidden"
941
942 async def test_delete_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
943 r = await _repo(db_session)
944 await db_session.commit()
945 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
946 result = await execute_delete_label(
947 repo_id=r.repo_id, label_id=created.data["label_id"], actor="eve"
948 )
949 assert result.ok is False
950 assert result.error_code == "forbidden"
951
952 async def test_create_label_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
953 r = await _repo(db_session)
954 await db_session.commit()
955 result = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a")
956 assert result.ok is False
957 assert result.error_code == "forbidden"
958
959
960 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
961
962
963 class TestE2EAuthGate:
964 """Every write tool call without auth must return 401."""
965
966 async def test_create_issue_no_auth(
967 self, http_client: AsyncClient, db_session: AsyncSession
968 ) -> None:
969 r = await _repo(db_session)
970 resp = await http_client.post(
971 "/mcp",
972 json=_tools_call("musehub_create_issue", {"repo_id": r.repo_id, "title": "T"}),
973 headers={"Content-Type": "application/json"},
974 )
975 assert resp.status_code == 401
976
977 async def test_create_repo_no_auth(
978 self, http_client: AsyncClient, db_session: AsyncSession
979 ) -> None:
980 resp = await http_client.post(
981 "/mcp",
982 json=_tools_call("musehub_create_repo", {"name": "test-repo"}),
983 headers={"Content-Type": "application/json"},
984 )
985 assert resp.status_code == 401
986
987 async def test_create_proposal_no_auth(
988 self, http_client: AsyncClient, db_session: AsyncSession
989 ) -> None:
990 resp = await http_client.post(
991 "/mcp",
992 json=_tools_call("musehub_create_proposal", {
993 "repo_id": "x", "title": "T", "from_branch": "a", "to_branch": "b"
994 }),
995 headers={"Content-Type": "application/json"},
996 )
997 assert resp.status_code == 401
998
999 async def test_merge_proposal_no_auth(
1000 self, http_client: AsyncClient, db_session: AsyncSession
1001 ) -> None:
1002 resp = await http_client.post(
1003 "/mcp",
1004 json=_tools_call("musehub_merge_proposal", {"repo_id": "x", "proposal_id": "y"}),
1005 headers={"Content-Type": "application/json"},
1006 )
1007 assert resp.status_code == 401
1008
1009 async def test_create_release_no_auth(
1010 self, http_client: AsyncClient, db_session: AsyncSession
1011 ) -> None:
1012 resp = await http_client.post(
1013 "/mcp",
1014 json=_tools_call("musehub_create_release", {"repo_id": "x", "tag": "v1.0", "title": "R"}),
1015 headers={"Content-Type": "application/json"},
1016 )
1017 assert resp.status_code == 401
1018
1019
1020
1021 class TestE2EAuthenticatedWrite:
1022 """Write tools succeed when auth is present."""
1023
1024 async def test_create_issue_with_auth(
1025 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
1026 ) -> None:
1027 r = await _repo(db_session)
1028 await db_session.commit()
1029
1030 resp = await http_client.post(
1031 "/mcp",
1032 json=_tools_call("musehub_create_issue", {
1033 "repo_id": r.repo_id, "title": "Auth issue"
1034 }),
1035 headers=auth_headers,
1036 )
1037 assert resp.status_code == 200
1038 result = resp.json()["result"]
1039 assert result["isError"] is False
1040 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
1041 assert payload["title"] == "Auth issue"
1042
1043 async def test_create_repo_with_auth(
1044 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
1045 ) -> None:
1046 resp = await http_client.post(
1047 "/mcp",
1048 json=_tools_call("musehub_create_repo", {
1049 "name": "e2e-authed-repo",
1050 "owner": "testuser",
1051 }),
1052 headers=auth_headers,
1053 )
1054 assert resp.status_code == 200
1055 result = resp.json()["result"]
1056 assert result["isError"] is False
1057 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
1058 assert "repo_id" in payload
1059
1060
1061 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
1062
1063
1064 class TestStressWriteTools:
1065 async def test_20_sequential_issues_have_sequential_numbers(
1066 self, db_session: AsyncSession
1067 ) -> None:
1068 r = await _repo(db_session)
1069 await db_session.commit()
1070 numbers = []
1071 for i in range(20):
1072 result = await execute_create_issue(
1073 repo_id=r.repo_id, title=f"Issue {i}", actor="alice"
1074 )
1075 assert result.ok is True
1076 numbers.append(result.data["number"])
1077 assert numbers == list(range(1, 21))
1078
1079
1080
1081 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
1082
1083
1084 class TestDataIntegrityIssue:
1085 async def test_created_issue_retrievable(self, db_session: AsyncSession) -> None:
1086 r = await _repo(db_session)
1087 await db_session.commit()
1088 await execute_create_issue(
1089 repo_id=r.repo_id, title="Retrievable issue", actor="alice"
1090 )
1091 issues_result = await execute_list_issues(r.repo_id, state="open")
1092 assert issues_result.ok is True
1093 titles = [i["title"] for i in issues_result.data["issues"]]
1094 assert "Retrievable issue" in titles
1095
1096 async def test_closed_issue_state_persisted(self, db_session: AsyncSession) -> None:
1097 r = await _repo(db_session)
1098 await db_session.commit()
1099 created = await execute_create_issue(
1100 repo_id=r.repo_id, title="Close me", actor="alice"
1101 )
1102 num = created.data["number"]
1103
1104 await execute_update_issue(repo_id=r.repo_id, issue_number=num, state="closed", actor="alice")
1105
1106 issues_result = await execute_list_issues(r.repo_id, state="closed")
1107 assert issues_result.ok is True
1108 numbers = [i["number"] for i in issues_result.data["issues"]]
1109 assert num in numbers
1110
1111
1112 class TestDataIntegrityProposal:
1113 async def test_created_proposal_retrievable(self, db_session: AsyncSession) -> None:
1114 r = await _repo(db_session)
1115 await _commit_and_branch(db_session, r.repo_id, "main")
1116 await _commit_and_branch(db_session, r.repo_id, "feat-di")
1117 await db_session.commit()
1118
1119 proposal_result = await execute_create_proposal(
1120 repo_id=r.repo_id,
1121 title="DI proposal",
1122 from_branch="feat-di",
1123 to_branch="main",
1124 actor="alice",
1125 )
1126 assert proposal_result.ok is True
1127
1128 list_result = await execute_list_proposals(r.repo_id, state="open")
1129 assert list_result.ok is True
1130 titles = [p["title"] for p in list_result.data["pulls"]]
1131 assert "DI proposal" in titles
1132
1133
1134 class TestDataIntegrityRelease:
1135 async def test_release_tag_persisted(self, db_session: AsyncSession) -> None:
1136 r = await _repo(db_session)
1137 await db_session.commit()
1138 result = await execute_create_release(
1139 repo_id=r.repo_id, tag="v3.7.2", title="Minor release", actor="alice"
1140 )
1141 assert result.ok is True
1142 assert result.data["tag"] == "v3.7.2"
1143
1144 async def test_release_author_persisted(self, db_session: AsyncSession) -> None:
1145 r = await _repo(db_session)
1146 await db_session.commit()
1147 result = await execute_create_release(
1148 repo_id=r.repo_id, tag="v0.1.0", actor="alice"
1149 )
1150 assert result.ok is True
1151 assert result.data["author"] == "alice"
1152
1153
1154 # ── Layer 6 — Security ────────────────────────────────────────────────────────
1155
1156
1157 class TestSecurityWriteTools:
1158 def test_all_write_tools_in_auth_gate_set(self) -> None:
1159 """All MUSEHUB_WRITE_TOOLS must be in MUSEHUB_WRITE_TOOL_NAMES."""
1160 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
1161 write_tool_names_from_list = {t["name"] for t in MUSEHUB_WRITE_TOOLS}
1162 # All write tools in the list should be in the gate set.
1163 uncovered = write_tool_names_from_list - MUSEHUB_WRITE_TOOL_NAMES
1164 assert not uncovered, f"Write tools not in auth gate: {uncovered}"
1165
1166 async def test_create_proposal_same_branch_guard(self, db_session: AsyncSession) -> None:
1167 r = await _repo(db_session)
1168 await db_session.commit()
1169 result = await execute_create_proposal(
1170 repo_id=r.repo_id, title="T", from_branch="main", to_branch="main", actor="alice"
1171 )
1172 assert result.ok is False
1173
1174 async def test_duplicate_label_rejected(self, db_session: AsyncSession) -> None:
1175 r = await _repo(db_session)
1176 await db_session.commit()
1177 await execute_create_label(repo_id=r.repo_id, name="sec-label", color="aabbcc")
1178 result = await execute_create_label(
1179 repo_id=r.repo_id, name="sec-label", color="ddeeff"
1180 )
1181 assert result.ok is False
1182
1183 async def test_create_issue_comment_on_wrong_issue_safe(
1184 self, db_session: AsyncSession
1185 ) -> None:
1186 """create_issue_comment on non-existent issue must return not_found, not crash."""
1187 r = await _repo(db_session)
1188 await db_session.commit()
1189 result = await execute_create_issue_comment(
1190 repo_id=r.repo_id, issue_number=9999, body="hack", actor="eve"
1191 )
1192 assert result.ok is False
1193 assert result.error_code == "issue_not_found"
1194
1195
1196 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
1197
1198
1199 class TestPerformanceWriteTools:
1200 async def test_10_sequential_issues_under_500ms(
1201 self, db_session: AsyncSession
1202 ) -> None:
1203 r = await _repo(db_session)
1204 await db_session.commit()
1205 start = time.perf_counter()
1206 for i in range(10):
1207 result = await execute_create_issue(
1208 repo_id=r.repo_id, title=f"Perf issue {i}", actor="alice"
1209 )
1210 assert result.ok is True
1211 elapsed_ms = (time.perf_counter() - start) * 1000
1212 assert elapsed_ms < 500, f"10 issues took {elapsed_ms:.1f} ms"
1213
1214 async def test_5_sequential_repos_under_1000ms(
1215 self, db_session: AsyncSession
1216 ) -> None:
1217 start = time.perf_counter()
1218 for i in range(5):
1219 result = await execute_create_repo(
1220 name=f"perf-repo-{i}",
1221 owner="alice",
1222 owner_user_id="uid-alice",
1223 initialize=False,
1224 )
1225 assert result.ok is True
1226 elapsed_ms = (time.perf_counter() - start) * 1000
1227 assert elapsed_ms < 1000, f"5 repos took {elapsed_ms:.1f} ms"
1228
1229
1230 # ── Issue state transition tests ───────────────────────────────────────────────
1231
1232
1233 class TestIntegrationCloseIssue:
1234 async def test_close_open_issue(self, db_session: AsyncSession) -> None:
1235 r = await _repo(db_session)
1236 await db_session.commit()
1237 created = await execute_create_issue(repo_id=r.repo_id, title="Bug to close", actor="alice")
1238 num: int = created.data["number"]
1239
1240 result = await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1241 assert result.ok is True
1242 assert result.data["state"] == "closed"
1243 assert result.data["number"] == num
1244
1245 async def test_close_already_closed_is_idempotent(self, db_session: AsyncSession) -> None:
1246 r = await _repo(db_session)
1247 await db_session.commit()
1248 created = await execute_create_issue(repo_id=r.repo_id, title="Double close", actor="alice")
1249 num: int = created.data["number"]
1250
1251 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1252 result = await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1253 assert result.ok is True
1254 assert result.data["state"] == "closed"
1255
1256 async def test_close_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1257 r = await _repo(db_session)
1258 await db_session.commit()
1259 result = await execute_close_issue(repo_id=r.repo_id, issue_number=9999, actor="alice")
1260 assert result.ok is False
1261 assert result.error_code == "issue_not_found"
1262
1263 async def test_close_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
1264 result = await execute_close_issue(repo_id=_uid(), issue_number=1, actor="alice")
1265 assert result.ok is False
1266 # close_issue returns None for unknown repo (issue not found path)
1267 assert result.error_code in ("issue_not_found", "repo_not_found", "invalid_args")
1268
1269
1270 class TestIntegrationReopenIssue:
1271 async def test_reopen_closed_issue(self, db_session: AsyncSession) -> None:
1272 r = await _repo(db_session)
1273 await db_session.commit()
1274 created = await execute_create_issue(repo_id=r.repo_id, title="Reopen me", actor="alice")
1275 num: int = created.data["number"]
1276 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1277
1278 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1279 assert result.ok is True
1280 assert result.data["state"] == "open"
1281 assert result.data["number"] == num
1282
1283 async def test_reopen_already_open_is_idempotent(self, db_session: AsyncSession) -> None:
1284 r = await _repo(db_session)
1285 await db_session.commit()
1286 created = await execute_create_issue(repo_id=r.repo_id, title="Already open", actor="alice")
1287 num: int = created.data["number"]
1288
1289 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1290 assert result.ok is True
1291 assert result.data["state"] == "open"
1292
1293 async def test_reopen_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1294 r = await _repo(db_session)
1295 await db_session.commit()
1296 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=9999, actor="alice")
1297 assert result.ok is False
1298 assert result.error_code == "issue_not_found"
1299
1300
1301 class TestIntegrationAssignIssue:
1302 async def test_assign_sets_assignee(self, db_session: AsyncSession) -> None:
1303 r = await _repo(db_session)
1304 await db_session.commit()
1305 created = await execute_create_issue(repo_id=r.repo_id, title="Assignable", actor="alice")
1306 num: int = created.data["number"]
1307
1308 result = await execute_assign_issue(
1309 repo_id=r.repo_id, issue_number=num, assignee="bob", actor="alice"
1310 )
1311 assert result.ok is True
1312 assert result.data["assignee"] == "bob"
1313
1314 async def test_unassign_with_empty_string(self, db_session: AsyncSession) -> None:
1315 r = await _repo(db_session)
1316 await db_session.commit()
1317 created = await execute_create_issue(repo_id=r.repo_id, title="Unassignable", actor="alice")
1318 num: int = created.data["number"]
1319 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="bob", actor="alice")
1320
1321 result = await execute_assign_issue(
1322 repo_id=r.repo_id, issue_number=num, assignee="", actor="alice"
1323 )
1324 assert result.ok is True
1325 assert result.data["assignee"] is None or result.data["assignee"] == ""
1326
1327 async def test_assign_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1328 r = await _repo(db_session)
1329 await db_session.commit()
1330 result = await execute_assign_issue(
1331 repo_id=r.repo_id, issue_number=9999, assignee="bob", actor="alice"
1332 )
1333 assert result.ok is False
1334 assert result.error_code == "issue_not_found"
1335
1336
1337 class TestIntegrationSetIssueLabels:
1338 async def test_set_labels_replaces_all(self, db_session: AsyncSession) -> None:
1339 r = await _repo(db_session)
1340 await db_session.commit()
1341 created = await execute_create_issue(
1342 repo_id=r.repo_id, title="Label me", labels=["bug"], actor="alice"
1343 )
1344 num: int = created.data["number"]
1345
1346 result = await execute_update_issue_labels(
1347 repo_id=r.repo_id, issue_number=num, labels=["enhancement", "help-wanted"], actor="alice"
1348 )
1349 assert result.ok is True
1350 assert set(result.data["labels"]) == {"enhancement", "help-wanted"}
1351
1352 async def test_set_empty_labels_clears_all(self, db_session: AsyncSession) -> None:
1353 r = await _repo(db_session)
1354 await db_session.commit()
1355 created = await execute_create_issue(
1356 repo_id=r.repo_id, title="Clear labels", labels=["bug"], actor="alice"
1357 )
1358 num: int = created.data["number"]
1359
1360 result = await execute_update_issue_labels(
1361 repo_id=r.repo_id, issue_number=num, labels=[], actor="alice"
1362 )
1363 assert result.ok is True
1364 assert result.data["labels"] == []
1365
1366 async def test_set_labels_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1367 r = await _repo(db_session)
1368 await db_session.commit()
1369 result = await execute_update_issue_labels(
1370 repo_id=r.repo_id, issue_number=9999, labels=["bug"], actor="alice"
1371 )
1372 assert result.ok is False
1373 assert result.error_code == "issue_not_found"
1374
1375
1376 class TestIntegrationRemoveIssueLabel:
1377 async def test_remove_existing_label(self, db_session: AsyncSession) -> None:
1378 r = await _repo(db_session)
1379 await db_session.commit()
1380 created = await execute_create_issue(
1381 repo_id=r.repo_id, title="Multi-label", labels=["bug", "enhancement"], actor="alice"
1382 )
1383 num: int = created.data["number"]
1384
1385 result = await execute_remove_issue_label(
1386 repo_id=r.repo_id, issue_number=num, label="bug", actor="alice"
1387 )
1388 assert result.ok is True
1389 assert "bug" not in result.data["labels"]
1390 assert "enhancement" in result.data["labels"]
1391
1392 async def test_remove_absent_label_is_idempotent(self, db_session: AsyncSession) -> None:
1393 r = await _repo(db_session)
1394 await db_session.commit()
1395 created = await execute_create_issue(
1396 repo_id=r.repo_id, title="No label", labels=[], actor="alice"
1397 )
1398 num: int = created.data["number"]
1399
1400 result = await execute_remove_issue_label(
1401 repo_id=r.repo_id, issue_number=num, label="nonexistent", actor="alice"
1402 )
1403 assert result.ok is True
1404 assert result.data["labels"] == []
1405
1406 async def test_remove_label_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1407 r = await _repo(db_session)
1408 await db_session.commit()
1409 result = await execute_remove_issue_label(
1410 repo_id=r.repo_id, issue_number=9999, label="bug", actor="alice"
1411 )
1412 assert result.ok is False
1413 assert result.error_code == "issue_not_found"
1414
1415
1416 class TestDataIntegrityIssueStateTransitions:
1417 async def test_close_then_reopen_cycle(self, db_session: AsyncSession) -> None:
1418 r = await _repo(db_session)
1419 await db_session.commit()
1420 created = await execute_create_issue(repo_id=r.repo_id, title="Cycle issue", actor="alice")
1421 num: int = created.data["number"]
1422
1423 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1424 closed_result = await execute_list_issues(r.repo_id, state="closed")
1425 assert num in [i["number"] for i in closed_result.data["issues"]]
1426
1427 await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1428 open_result = await execute_list_issues(r.repo_id, state="open")
1429 assert num in [i["number"] for i in open_result.data["issues"]]
1430
1431 async def test_assign_then_unassign_persisted(self, db_session: AsyncSession) -> None:
1432 r = await _repo(db_session)
1433 await db_session.commit()
1434 created = await execute_create_issue(repo_id=r.repo_id, title="Assign cycle", actor="alice")
1435 num: int = created.data["number"]
1436
1437 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="carol", actor="alice")
1438 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="", actor="alice")
1439
1440 open_list = await execute_list_issues(r.repo_id, state="open")
1441 issue = next((i for i in open_list.data["issues"] if i["number"] == num), None)
1442 assert issue is not None
1443 assert issue.get("assignee") is None or issue.get("assignee") == ""
1444
1445
1446 class TestIntegrationDeleteIssueComment:
1447 """Tests for execute_delete_issue_comment."""
1448
1449 async def test_delete_existing_comment(self, db_session: AsyncSession) -> None:
1450 """Deleting an existing comment returns ok=True and deleted=True."""
1451 from musehub.mcp.write_tools.issues import execute_create_issue_comment
1452
1453 r = await _repo(db_session)
1454 await db_session.commit()
1455
1456 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Issue with comment", actor="alice")
1457 num: int = issue_result.data["number"]
1458
1459 comment_result = await execute_create_issue_comment(
1460 repo_id=r.repo_id, issue_number=num, body="A comment", actor="alice"
1461 )
1462 assert comment_result.ok is True
1463 comment_id: str = comment_result.data["comment_id"]
1464
1465 delete_result = await execute_delete_issue_comment(
1466 repo_id=r.repo_id, issue_number=num, comment_id=comment_id, actor="alice"
1467 )
1468 assert delete_result.ok is True
1469 assert delete_result.data["deleted"] is True
1470 assert delete_result.data["comment_id"] == comment_id
1471
1472 async def test_delete_unknown_comment_returns_error(self, db_session: AsyncSession) -> None:
1473 """Deleting a non-existent comment returns ok=False with comment_not_found."""
1474 r = await _repo(db_session)
1475 await db_session.commit()
1476
1477 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Commentless issue", actor="alice")
1478 num: int = issue_result.data["number"]
1479
1480 result = await execute_delete_issue_comment(
1481 repo_id=r.repo_id, issue_number=num, comment_id="ghost-comment-id", actor="alice"
1482 )
1483 assert result.ok is False
1484 assert result.error_code == "comment_not_found"
1485
1486 async def test_delete_comment_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1487 """Non-owner cannot delete a comment — returns forbidden."""
1488 from musehub.mcp.write_tools.issues import execute_create_issue_comment
1489
1490 r = await _repo(db_session, owner="alice")
1491 await db_session.commit()
1492
1493 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Protected issue", actor="alice")
1494 num: int = issue_result.data["number"]
1495
1496 comment_result = await execute_create_issue_comment(
1497 repo_id=r.repo_id, issue_number=num, body="Comment to protect", actor="alice"
1498 )
1499 comment_id: str = comment_result.data["comment_id"]
1500
1501 result = await execute_delete_issue_comment(
1502 repo_id=r.repo_id, issue_number=num, comment_id=comment_id, actor="carol"
1503 )
1504 assert result.ok is False
1505 assert result.error_code == "forbidden"
1506
1507
1508 class TestIntegrationCollaborators:
1509 """Tests for execute_list/invite/update/remove_collaborator."""
1510
1511 async def test_invite_and_list_collaborator(self, db_session: AsyncSession) -> None:
1512 """Inviting a collaborator then listing shows the new entry."""
1513 r = await _repo(db_session, owner="alice")
1514 await db_session.commit()
1515
1516 invite = await execute_invite_collaborator(
1517 repo_id=r.repo_id, handle="carol", permission="write", actor="alice"
1518 )
1519 assert invite.ok is True
1520 assert invite.data["handle"] == "carol"
1521 assert invite.data["permission"] == "write"
1522
1523 listing = await execute_list_collaborators(repo_id=r.repo_id, actor="alice")
1524 assert listing.ok is True
1525 handles = [c["handle"] for c in listing.data["collaborators"]]
1526 assert "carol" in handles
1527
1528 async def test_invite_duplicate_returns_conflict(self, db_session: AsyncSession) -> None:
1529 """Inviting the same handle twice returns error_code='conflict'."""
1530 r = await _repo(db_session, owner="alice")
1531 await db_session.commit()
1532
1533 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1534 result = await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1535 assert result.ok is False
1536 assert result.error_code == "conflict"
1537
1538 async def test_update_collaborator_permission(self, db_session: AsyncSession) -> None:
1539 """Updating a collaborator's permission is reflected immediately."""
1540 r = await _repo(db_session, owner="alice")
1541 await db_session.commit()
1542
1543 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", permission="read", actor="alice")
1544 update = await execute_update_collaborator_permission(
1545 repo_id=r.repo_id, handle="carol", permission="admin", actor="alice"
1546 )
1547 assert update.ok is True
1548 assert update.data["permission"] == "admin"
1549
1550 async def test_remove_collaborator(self, db_session: AsyncSession) -> None:
1551 """Removing a collaborator causes them to disappear from the list."""
1552 r = await _repo(db_session, owner="alice")
1553 await db_session.commit()
1554
1555 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1556 remove = await execute_remove_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1557 assert remove.ok is True
1558 assert remove.data["removed"] is True
1559
1560 listing = await execute_list_collaborators(repo_id=r.repo_id, actor="alice")
1561 handles = [c["handle"] for c in listing.data["collaborators"]]
1562 assert "carol" not in handles
1563
1564 async def test_invite_forbidden_for_non_admin(self, db_session: AsyncSession) -> None:
1565 """A non-admin collaborator cannot invite others."""
1566 r = await _repo(db_session, owner="alice")
1567 await db_session.commit()
1568
1569 result = await execute_invite_collaborator(
1570 repo_id=r.repo_id, handle="dave", permission="write", actor="carol"
1571 )
1572 assert result.ok is False
1573 assert result.error_code == "forbidden"
1574
1575 async def test_list_collaborators_unknown_repo(self, db_session: AsyncSession) -> None:
1576 """Listing collaborators for a non-existent repo returns repo_not_found."""
1577 result = await execute_list_collaborators(repo_id="ghost-repo-id", actor="alice")
1578 assert result.ok is False
1579 assert result.error_code == "repo_not_found"
1580
1581
1582 class TestIntegrationWebhooksMCP:
1583 """Tests for execute_create/list/delete_webhook via MCP layer."""
1584
1585 async def test_create_and_list_webhook(self, db_session: AsyncSession) -> None:
1586 """Creating a webhook then listing shows the new subscription."""
1587 r = await _repo(db_session, owner="alice")
1588 await db_session.commit()
1589
1590 created = await execute_create_webhook(
1591 repo_id=r.repo_id,
1592 url="https://example.com/hook",
1593 events=["push", "issue"],
1594 actor="alice",
1595 )
1596 assert created.ok is True
1597 assert created.data["url"] == "https://example.com/hook"
1598 assert set(created.data["events"]) == {"push", "issue"}
1599 webhook_id: str = created.data["webhook_id"]
1600
1601 listing = await execute_list_webhooks(repo_id=r.repo_id, actor="alice")
1602 assert listing.ok is True
1603 ids = [w["webhook_id"] for w in listing.data["webhooks"]]
1604 assert webhook_id in ids
1605
1606 async def test_delete_webhook(self, db_session: AsyncSession) -> None:
1607 """Deleting a webhook removes it from the listing."""
1608 r = await _repo(db_session, owner="alice")
1609 await db_session.commit()
1610
1611 created = await execute_create_webhook(
1612 repo_id=r.repo_id,
1613 url="https://example.com/delete-hook",
1614 events=["push"],
1615 actor="alice",
1616 )
1617 webhook_id: str = created.data["webhook_id"]
1618
1619 deleted = await execute_delete_webhook(repo_id=r.repo_id, webhook_id=webhook_id, actor="alice")
1620 assert deleted.ok is True
1621 assert deleted.data["deleted"] is True
1622
1623 listing = await execute_list_webhooks(repo_id=r.repo_id, actor="alice")
1624 ids = [w["webhook_id"] for w in listing.data["webhooks"]]
1625 assert webhook_id not in ids
1626
1627 async def test_create_webhook_invalid_event_type(self, db_session: AsyncSession) -> None:
1628 """Unknown event types are rejected with invalid_args."""
1629 r = await _repo(db_session, owner="alice")
1630 await db_session.commit()
1631
1632 result = await execute_create_webhook(
1633 repo_id=r.repo_id,
1634 url="https://example.com/hook",
1635 events=["not_a_real_event"],
1636 actor="alice",
1637 )
1638 assert result.ok is False
1639 assert result.error_code == "invalid_args"
1640
1641 async def test_create_webhook_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1642 """Non-owner cannot create webhooks."""
1643 r = await _repo(db_session, owner="alice")
1644 await db_session.commit()
1645
1646 result = await execute_create_webhook(
1647 repo_id=r.repo_id,
1648 url="https://example.com/hook",
1649 events=["push"],
1650 actor="carol",
1651 )
1652 assert result.ok is False
1653 assert result.error_code == "forbidden"
1654
1655 async def test_delete_unknown_webhook_returns_error(self, db_session: AsyncSession) -> None:
1656 """Deleting a non-existent webhook returns webhook_not_found."""
1657 r = await _repo(db_session, owner="alice")
1658 await db_session.commit()
1659
1660 result = await execute_delete_webhook(
1661 repo_id=r.repo_id, webhook_id="ghost-webhook-id", actor="alice"
1662 )
1663 assert result.ok is False
1664 assert result.error_code == "webhook_not_found"
1665
1666 async def test_list_webhooks_forbidden_without_auth(self, db_session: AsyncSession) -> None:
1667 """Empty actor (unauthenticated) is forbidden from listing webhooks."""
1668 r = await _repo(db_session, owner="alice")
1669 await db_session.commit()
1670
1671 result = await execute_list_webhooks(repo_id=r.repo_id, actor="")
1672 assert result.ok is False
1673 assert result.error_code == "forbidden"
1674
1675 async def test_list_webhooks_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1676 """Non-owner carol cannot list webhooks — they may contain sensitive URLs."""
1677 r = await _repo(db_session, owner="alice")
1678 await db_session.commit()
1679
1680 result = await execute_list_webhooks(repo_id=r.repo_id, actor="carol")
1681 assert result.ok is False
1682 assert result.error_code == "forbidden"
1683
1684
1685 class TestIntegrationReleaseAssets:
1686 """Tests for execute_attach/delete_release_asset via MCP layer."""
1687
1688 async def test_attach_and_delete_asset(self, db_session: AsyncSession) -> None:
1689 """Attaching an asset then deleting it returns deleted=True."""
1690 r = await _repo(db_session, owner="alice")
1691 await db_session.commit()
1692
1693 release_result = await execute_create_release(
1694 repo_id=r.repo_id, tag="v1.0.0", title="First release", actor="alice"
1695 )
1696 assert release_result.ok is True
1697
1698 attach = await execute_attach_release_asset(
1699 repo_id=r.repo_id,
1700 tag="v1.0.0",
1701 name="myapp-v1.0.0.tar.gz",
1702 download_url="https://cdn.example.com/myapp-v1.0.0.tar.gz",
1703 actor="alice",
1704 )
1705 assert attach.ok is True
1706 assert attach.data["name"] == "myapp-v1.0.0.tar.gz"
1707 asset_id: str = attach.data["asset_id"]
1708
1709 delete = await execute_delete_release_asset(
1710 repo_id=r.repo_id, tag="v1.0.0", asset_id=asset_id, actor="alice"
1711 )
1712 assert delete.ok is True
1713 assert delete.data["deleted"] is True
1714
1715 async def test_attach_asset_release_not_found(self, db_session: AsyncSession) -> None:
1716 """Attaching an asset to a non-existent release returns release_not_found."""
1717 r = await _repo(db_session, owner="alice")
1718 await db_session.commit()
1719
1720 result = await execute_attach_release_asset(
1721 repo_id=r.repo_id,
1722 tag="v9.9.9",
1723 name="ghost.tar.gz",
1724 download_url="https://cdn.example.com/ghost.tar.gz",
1725 actor="alice",
1726 )
1727 assert result.ok is False
1728 assert result.error_code == "release_not_found"
1729
1730 async def test_attach_asset_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1731 """Non-owner cannot attach assets."""
1732 r = await _repo(db_session, owner="alice")
1733 await db_session.commit()
1734
1735 await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
1736
1737 result = await execute_attach_release_asset(
1738 repo_id=r.repo_id,
1739 tag="v1.0.0",
1740 name="evil.tar.gz",
1741 download_url="https://cdn.example.com/evil.tar.gz",
1742 actor="carol",
1743 )
1744 assert result.ok is False
1745 assert result.error_code == "forbidden"
1746
1747
1748 class TestIntegrationProposalComments:
1749 """Integration tests for execute_list_proposal_comments."""
1750
1751 async def test_list_proposal_comments_happy_path(self, db_session: AsyncSession) -> None:
1752 """list_proposal_comments returns threaded comments for a proposal."""
1753 r = await _repo(db_session, owner="alice")
1754 await _commit_and_branch(db_session, r.repo_id, "main")
1755 await _commit_and_branch(db_session, r.repo_id, "feat-pc")
1756 await db_session.commit()
1757
1758 proposal = await execute_create_proposal(
1759 repo_id=r.repo_id,
1760 title="Test proposal",
1761 from_branch="feat-pc",
1762 to_branch="main",
1763 actor="alice",
1764 )
1765 assert proposal.ok is True
1766 proposal_id: str = proposal.data["proposal_id"]
1767
1768 comment = await execute_create_proposal_comment(
1769 repo_id=r.repo_id,
1770 proposal_id=proposal_id,
1771 body="Looks good!",
1772 actor="alice",
1773 )
1774 assert comment.ok is True
1775
1776 result = await execute_list_proposal_comments(
1777 repo_id=r.repo_id,
1778 proposal_id=proposal_id,
1779 actor="alice",
1780 )
1781 assert result.ok is True
1782 assert result.data["total"] == 1
1783 assert len(result.data["comments"]) == 1
1784 assert result.data["comments"][0]["body"] == "Looks good!"
1785
1786 async def test_list_proposal_comments_requires_auth(self, db_session: AsyncSession) -> None:
1787 """list_proposal_comments requires authentication."""
1788 r = await _repo(db_session, owner="alice")
1789 await db_session.commit()
1790
1791 result = await execute_list_proposal_comments(
1792 repo_id=r.repo_id,
1793 proposal_id="any-uuid",
1794 actor="",
1795 )
1796 assert result.ok is False
1797 assert result.error_code == "forbidden"
1798
1799
1800 class TestIntegrationProposalReviewers:
1801 """Integration tests for request/remove proposal reviewers and list reviews."""
1802
1803 async def test_request_and_list_reviewers(self, db_session: AsyncSession) -> None:
1804 """request_proposal_reviewers creates pending rows; list_proposal_reviews returns them."""
1805 r = await _repo(db_session, owner="alice")
1806 await _commit_and_branch(db_session, r.repo_id, "main")
1807 await _commit_and_branch(db_session, r.repo_id, "feat-rv")
1808 await db_session.commit()
1809
1810 proposal = await execute_create_proposal(
1811 repo_id=r.repo_id,
1812 title="Review me",
1813 from_branch="feat-rv",
1814 to_branch="main",
1815 actor="alice",
1816 )
1817 assert proposal.ok is True
1818 proposal_id: str = proposal.data["proposal_id"]
1819
1820 req = await execute_request_proposal_reviewers(
1821 repo_id=r.repo_id,
1822 proposal_id=proposal_id,
1823 reviewers=["bob", "carol"],
1824 actor="alice",
1825 )
1826 assert req.ok is True
1827 assert req.data["total"] == 2
1828 reviewer_handles = {rv["reviewer"] for rv in req.data["reviews"]}
1829 assert "bob" in reviewer_handles
1830 assert "carol" in reviewer_handles
1831
1832 lst = await execute_list_proposal_reviews(
1833 repo_id=r.repo_id,
1834 proposal_id=proposal_id,
1835 actor="alice",
1836 )
1837 assert lst.ok is True
1838 assert lst.data["total"] == 2
1839
1840 async def test_remove_reviewer(self, db_session: AsyncSession) -> None:
1841 """remove_proposal_reviewer removes a pending reviewer."""
1842 r = await _repo(db_session, owner="alice")
1843 await _commit_and_branch(db_session, r.repo_id, "main")
1844 await _commit_and_branch(db_session, r.repo_id, "feat-rm")
1845 await db_session.commit()
1846
1847 proposal = await execute_create_proposal(
1848 repo_id=r.repo_id,
1849 title="Remove reviewer",
1850 from_branch="feat-rm",
1851 to_branch="main",
1852 actor="alice",
1853 )
1854 proposal_id: str = proposal.data["proposal_id"]
1855
1856 await execute_request_proposal_reviewers(
1857 repo_id=r.repo_id,
1858 proposal_id=proposal_id,
1859 reviewers=["bob"],
1860 actor="alice",
1861 )
1862
1863 remove = await execute_remove_proposal_reviewer(
1864 repo_id=r.repo_id,
1865 proposal_id=proposal_id,
1866 reviewer="bob",
1867 actor="alice",
1868 )
1869 assert remove.ok is True
1870 assert remove.data["total"] == 0
1871
1872 async def test_request_reviewers_forbidden_for_non_write(self, db_session: AsyncSession) -> None:
1873 """Unauthenticated user cannot request reviewers."""
1874 r = await _repo(db_session, owner="alice")
1875 await _commit_and_branch(db_session, r.repo_id, "main")
1876 await _commit_and_branch(db_session, r.repo_id, "feat-fw")
1877 await db_session.commit()
1878
1879 proposal = await execute_create_proposal(
1880 repo_id=r.repo_id,
1881 title="Forbidden",
1882 from_branch="feat-fw",
1883 to_branch="main",
1884 actor="alice",
1885 )
1886 proposal_id: str = proposal.data["proposal_id"]
1887
1888 result = await execute_request_proposal_reviewers(
1889 repo_id=r.repo_id,
1890 proposal_id=proposal_id,
1891 reviewers=["bob"],
1892 actor="",
1893 )
1894 assert result.ok is False
1895 assert result.error_code == "forbidden"
1896
1897 async def test_list_reviews_filtered_by_state(self, db_session: AsyncSession) -> None:
1898 """list_proposal_reviews state filter returns only matching rows."""
1899 r = await _repo(db_session, owner="alice")
1900 await _commit_and_branch(db_session, r.repo_id, "main")
1901 await _commit_and_branch(db_session, r.repo_id, "feat-flt")
1902 await db_session.commit()
1903
1904 proposal = await execute_create_proposal(
1905 repo_id=r.repo_id,
1906 title="Filter test",
1907 from_branch="feat-flt",
1908 to_branch="main",
1909 actor="alice",
1910 )
1911 proposal_id: str = proposal.data["proposal_id"]
1912
1913 await execute_request_proposal_reviewers(
1914 repo_id=r.repo_id,
1915 proposal_id=proposal_id,
1916 reviewers=["bob"],
1917 actor="alice",
1918 )
1919
1920 lst = await execute_list_proposal_reviews(
1921 repo_id=r.repo_id,
1922 proposal_id=proposal_id,
1923 state="pending",
1924 actor="alice",
1925 )
1926 assert lst.ok is True
1927 assert lst.data["total"] == 1
1928
1929 lst_approved = await execute_list_proposal_reviews(
1930 repo_id=r.repo_id,
1931 proposal_id=proposal_id,
1932 state="approved",
1933 actor="alice",
1934 )
1935 assert lst_approved.ok is True
1936 assert lst_approved.data["total"] == 0
1937
1938
1939 class TestIntegrationRepoManagement:
1940 """Integration tests for delete_repo, update_repo, transfer_repo_ownership."""
1941
1942 async def test_delete_repo_happy_path(self, db_session: AsyncSession) -> None:
1943 """Owner can delete their own repo."""
1944 r = await _repo(db_session, owner="alice")
1945 await db_session.commit()
1946
1947 result = await execute_delete_repo(repo_id=r.repo_id, actor="alice")
1948 assert result.ok is True
1949 assert result.data["deleted"] is True
1950 assert result.data["repo_id"] == r.repo_id
1951
1952 async def test_delete_repo_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1953 """Non-owner cannot delete a repo."""
1954 r = await _repo(db_session, owner="alice")
1955 await db_session.commit()
1956
1957 result = await execute_delete_repo(repo_id=r.repo_id, actor="carol")
1958 assert result.ok is False
1959 assert result.error_code == "forbidden"
1960
1961 async def test_delete_repo_requires_auth(self, db_session: AsyncSession) -> None:
1962 """Unauthenticated user cannot delete a repo."""
1963 r = await _repo(db_session, owner="alice")
1964 await db_session.commit()
1965
1966 result = await execute_delete_repo(repo_id=r.repo_id, actor="")
1967 assert result.ok is False
1968 assert result.error_code == "forbidden"
1969
1970 async def test_update_repo_description(self, db_session: AsyncSession) -> None:
1971 """Owner can update the repo description."""
1972 from musehub.mcp.write_tools.repos import execute_update_repo
1973 r = await _repo(db_session, owner="alice")
1974 await db_session.commit()
1975
1976 result = await execute_update_repo(
1977 repo_id=r.repo_id,
1978 actor="alice",
1979 description="Updated description",
1980 )
1981 assert result.ok is True
1982 assert result.data["description"] == "Updated description"
1983
1984 async def test_update_repo_visibility(self, db_session: AsyncSession) -> None:
1985 """Owner can change visibility from public to private."""
1986 from musehub.mcp.write_tools.repos import execute_update_repo
1987 r = await _repo(db_session, owner="alice", visibility="public")
1988 await db_session.commit()
1989
1990 result = await execute_update_repo(
1991 repo_id=r.repo_id,
1992 actor="alice",
1993 visibility="private",
1994 )
1995 assert result.ok is True
1996 assert result.data["visibility"] == "private"
1997
1998 async def test_update_repo_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1999 """Non-owner cannot update repo settings."""
2000 from musehub.mcp.write_tools.repos import execute_update_repo
2001 r = await _repo(db_session, owner="alice")
2002 await db_session.commit()
2003
2004 result = await execute_update_repo(
2005 repo_id=r.repo_id,
2006 actor="carol",
2007 description="Sneaky update",
2008 )
2009 assert result.ok is False
2010 assert result.error_code == "forbidden"
2011
2012 async def test_update_repo_requires_auth(self, db_session: AsyncSession) -> None:
2013 """Unauthenticated caller cannot update repo settings."""
2014 from musehub.mcp.write_tools.repos import execute_update_repo
2015 r = await _repo(db_session, owner="alice")
2016 await db_session.commit()
2017
2018 result = await execute_update_repo(
2019 repo_id=r.repo_id,
2020 actor="",
2021 description="Should fail",
2022 )
2023 assert result.ok is False
2024 assert result.error_code == "forbidden"
2025
2026 async def test_update_repo_not_found(self, db_session: AsyncSession) -> None:
2027 """Updating a non-existent repo returns repo_not_found."""
2028 from musehub.mcp.write_tools.repos import execute_update_repo
2029 result = await execute_update_repo(
2030 repo_id="00000000-0000-0000-0000-000000000000",
2031 actor="alice",
2032 name="ghost",
2033 )
2034 assert result.ok is False
2035 assert result.error_code == "repo_not_found"
2036
2037 async def test_patch_repo_settings_happy_path(self, db_session: AsyncSession) -> None:
2038 """Owner can patch repo settings."""
2039 r = await _repo(db_session, owner="alice")
2040 await db_session.commit()
2041
2042 result = await execute_update_repo(
2043 repo_id=r.repo_id,
2044 actor="alice",
2045 description="New description",
2046 visibility="private",
2047 )
2048 assert result.ok is True
2049 assert result.data["description"] == "New description"
2050 assert result.data["visibility"] == "private"
2051
2052 async def test_patch_repo_settings_forbidden_for_non_admin(self, db_session: AsyncSession) -> None:
2053 """Non-admin cannot patch repo settings."""
2054 r = await _repo(db_session, owner="alice")
2055 await db_session.commit()
2056
2057 result = await execute_update_repo(
2058 repo_id=r.repo_id,
2059 actor="carol",
2060 description="Evil update",
2061 )
2062 assert result.ok is False
2063 assert result.error_code == "forbidden"
2064
2065 async def test_transfer_repo_ownership_happy_path(self, db_session: AsyncSession) -> None:
2066 """Owner can transfer ownership to another user."""
2067 r = await _repo(db_session, owner="alice")
2068 await db_session.commit()
2069
2070 result = await execute_transfer_repo_ownership(
2071 repo_id=r.repo_id,
2072 new_owner="bob",
2073 actor="alice",
2074 )
2075 assert result.ok is True
2076 assert result.data["owner_user_id"] == "bob"
2077
2078 async def test_transfer_repo_ownership_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
2079 """Non-owner cannot transfer ownership."""
2080 r = await _repo(db_session, owner="alice")
2081 await db_session.commit()
2082
2083 result = await execute_transfer_repo_ownership(
2084 repo_id=r.repo_id,
2085 new_owner="evil",
2086 actor="carol",
2087 )
2088 assert result.ok is False
2089 assert result.error_code == "forbidden"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago