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