gabriel / musehub public
test_domains.py python
1,232 lines 43.4 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 54 days ago
1 """Section 25 — Domains: 8-layer test suite.
2
3 Covers musehub/services/musehub_domains.py and
4 musehub/api/routes/musehub/domains.py.
5
6 Layer map
7 ---------
8 1. Unit — compute_manifest_hash, _to_response, dataclasses
9 2. Integration — service functions against real PostgreSQL DB
10 3. E2E — HTTP client against full app
11 4. Stress — many domains, concurrent queries
12 5. Data Integrity — sort order, deprecated exclusion, hash correctness
13 6. Security — auth enforcement, duplicate scoped_id
14 7. Performance — timing budgets
15 8. Admin — domain verification endpoint
16 """
17 from __future__ import annotations
18
19 import asyncio
20 import json
21 import secrets
22 from collections.abc import Generator, Mapping
23 import time
24
25 import pytest
26 import pytest_asyncio
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import blob_id
31 from musehub.auth.request_signing import MSignContext, optional_signed_request, require_signed_request
32 from musehub.db.musehub_domain_models import MusehubDomain, MusehubDomainInstall
33 from datetime import datetime, timezone
34
35 from musehub.core.genesis import compute_identity_id, compute_repo_id
36 from musehub.db.musehub_identity_models import MusehubIdentity
37 from musehub.db.musehub_repo_models import MusehubRepo
38 from musehub.main import app
39 from musehub.types.json_types import JSONObject, StrDict
40 from musehub.services.musehub_domains import (
41 DomainListResponse,
42 DomainReposResponse,
43 DomainResponse,
44 _to_response,
45 compute_manifest_hash,
46 create_domain,
47 get_domain_by_id,
48 get_domain_by_scoped_id,
49 list_domains,
50 list_repos_for_domain,
51 record_domain_install,
52 )
53
54
55 # ---------------------------------------------------------------------------
56 # DB helpers
57 # ---------------------------------------------------------------------------
58
59
60 def _uid() -> str:
61 return secrets.token_hex(16)
62
63
64 async def _db_domain(
65 session: AsyncSession,
66 *,
67 author_slug: str = "alice",
68 slug: str | None = None,
69 display_name: str = "Test Domain",
70 description: str = "A test domain",
71 capabilities: JSONObject | None = None,
72 viewer_type: str = "generic",
73 version: str = "1.0.0",
74 install_count: int = 0,
75 is_verified: bool = False,
76 is_deprecated: bool = False,
77 ) -> MusehubDomain:
78 from datetime import datetime, timezone
79
80 slug = slug or f"domain-{_uid()[:8]}"
81 caps = capabilities or {"dimensions": [], "merge_semantics": "three_way"}
82 manifest_hash = compute_manifest_hash(caps)
83 domain = MusehubDomain(
84 domain_id=_uid(),
85 author_user_id=author_slug,
86 author_slug=author_slug,
87 slug=slug,
88 display_name=display_name,
89 description=description,
90 version=version,
91 manifest_hash=manifest_hash,
92 capabilities=caps,
93 viewer_type=viewer_type,
94 install_count=install_count,
95 is_verified=is_verified,
96 is_deprecated=is_deprecated,
97 created_at=datetime.now(timezone.utc),
98 updated_at=datetime.now(timezone.utc),
99 )
100 session.add(domain)
101 await session.flush()
102 return domain
103
104
105 async def _db_repo(
106 session: AsyncSession,
107 owner: str = "alice",
108 *,
109 domain_id: str | None = None,
110 visibility: str = "public",
111 deleted: bool = False,
112 ) -> MusehubRepo:
113 slug = f"repo-{_uid()[:8]}"
114 owner_id = compute_identity_id(owner.encode())
115 created_at = datetime.now(tz=timezone.utc)
116 repo = MusehubRepo(
117 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
118 name=slug,
119 slug=slug,
120 owner=owner,
121 owner_user_id=owner_id,
122 visibility=visibility,
123 domain_id=domain_id,
124 created_at=created_at,
125 updated_at=created_at,
126 )
127 session.add(repo)
128 await session.flush()
129 if deleted:
130 await session.delete(repo)
131 await session.flush()
132 return repo
133
134
135 # ===========================================================================
136 # Layer 1 — Unit
137 # ===========================================================================
138
139
140 class TestUnitComputeManifestHash:
141 def test_returns_hex_string(self) -> None:
142 h = compute_manifest_hash({"dimensions": []})
143 assert isinstance(h, str)
144 assert h.startswith("sha256:")
145 assert len(h) == 71 # sha256:<64-hex>
146
147 def test_deterministic(self) -> None:
148 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
149 assert compute_manifest_hash(caps) == compute_manifest_hash(caps)
150
151 def test_sorted_keys_order_independent(self) -> None:
152 caps_a = {"b": 1, "a": 2}
153 caps_b = {"a": 2, "b": 1}
154 assert compute_manifest_hash(caps_a) == compute_manifest_hash(caps_b)
155
156 def test_different_capabilities_different_hash(self) -> None:
157 h1 = compute_manifest_hash({"dimensions": []})
158 h2 = compute_manifest_hash({"dimensions": [{"name": "tempo"}]})
159 assert h1 != h2
160
161 def test_matches_manual_sha256(self) -> None:
162 caps = {"x": 1}
163 blob = json.dumps(caps, sort_keys=True, separators=(",", ":")).encode()
164 expected = blob_id(blob)
165 assert compute_manifest_hash(caps) == expected
166
167
168 class TestUnitToResponse:
169 """Unit tests for _to_response using an integration DB fixture to create real ORM rows."""
170
171 async def test_scoped_id_format(self, db_session: AsyncSession) -> None:
172 d = await _db_domain(db_session, author_slug="gabriel", slug="midi")
173 resp = _to_response(d)
174 assert resp.scoped_id == "@gabriel/midi"
175
176 async def test_install_count_preserved(self, db_session: AsyncSession) -> None:
177 d = await _db_domain(db_session, slug="midi-count", install_count=5)
178 resp = _to_response(d)
179 assert resp.install_count == 5
180
181 async def test_is_verified_preserved(self, db_session: AsyncSession) -> None:
182 d = await _db_domain(db_session, slug="midi-verified", is_verified=True)
183 resp = _to_response(d)
184 assert resp.is_verified is True
185
186 async def test_capabilities_copied(self, db_session: AsyncSession) -> None:
187 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
188 d = await _db_domain(db_session, slug="midi-caps", capabilities=caps)
189 resp = _to_response(d)
190 assert resp.capabilities == caps
191
192 async def test_none_capabilities_become_empty_dict(
193 self, db_session: AsyncSession
194 ) -> None:
195 d = await _db_domain(db_session, slug="midi-no-caps")
196 d.capabilities = None
197 resp = _to_response(d)
198 assert resp.capabilities == {}
199
200
201 class TestUnitDataclasses:
202 def test_domain_response_fields(self) -> None:
203 from datetime import datetime, timezone
204
205 dr = DomainResponse(
206 domain_id=_uid(),
207 author_slug="alice",
208 slug="midi",
209 scoped_id="@alice/midi",
210 display_name="MIDI",
211 description="",
212 version="1.0.0",
213 manifest_hash="abc",
214 capabilities={},
215 viewer_type="generic",
216 install_count=0,
217 is_verified=False,
218 is_deprecated=False,
219 created_at=datetime.now(timezone.utc),
220 updated_at=datetime.now(timezone.utc),
221 )
222 assert dr.scoped_id == "@alice/midi"
223
224 def test_domain_list_response_fields(self) -> None:
225 dlr = DomainListResponse(domains=[], total=0)
226 assert dlr.total == 0
227
228 def test_domain_repos_response_fields(self) -> None:
229 drr = DomainReposResponse(
230 domain_id=_uid(), scoped_id="@a/b", repos=[], total=0
231 )
232 assert drr.repos == []
233
234
235 # ===========================================================================
236 # Layer 2 — Integration
237 # ===========================================================================
238
239
240 class TestIntegrationListDomains:
241 async def test_returns_all_non_deprecated(self, db_session: AsyncSession) -> None:
242 await _db_domain(db_session, slug="midi", display_name="MIDI")
243 await _db_domain(db_session, slug="code", display_name="Code")
244 await _db_domain(db_session, slug="old", is_deprecated=True)
245 await db_session.flush()
246
247 result = await list_domains(db_session)
248 slugs = [d.slug for d in result.domains]
249 assert "midi" in slugs
250 assert "code" in slugs
251 assert "old" not in slugs
252
253 async def test_query_filters_by_display_name(self, db_session: AsyncSession) -> None:
254 await _db_domain(db_session, slug="piano", display_name="Piano Roll Domain")
255 await _db_domain(db_session, slug="genome", display_name="Genomics Domain")
256 await db_session.flush()
257
258 result = await list_domains(db_session, query="Piano")
259 assert len(result.domains) == 1
260 assert result.domains[0].slug == "piano"
261
262 async def test_verified_only_filter(self, db_session: AsyncSession) -> None:
263 await _db_domain(db_session, slug="v", is_verified=True)
264 await _db_domain(db_session, slug="u", is_verified=False)
265 await db_session.flush()
266
267 result = await list_domains(db_session, verified_only=True)
268 slugs = [d.slug for d in result.domains]
269 assert "v" in slugs
270 assert "u" not in slugs
271
272 async def test_pagination_page_size(self, db_session: AsyncSession) -> None:
273 for i in range(5):
274 await _db_domain(db_session, slug=f"d{i}")
275 await db_session.flush()
276
277 result = await list_domains(db_session, limit=2)
278 assert len(result.domains) == 2
279 assert result.total == 5
280 assert result.next_cursor is not None
281
282
283 class TestIntegrationGetDomain:
284 async def test_get_by_scoped_id_found(self, db_session: AsyncSession) -> None:
285 await _db_domain(db_session, author_slug="alice", slug="midi")
286 await db_session.flush()
287
288 result = await get_domain_by_scoped_id(db_session, "alice", "midi")
289 assert result is not None
290 assert result.scoped_id == "@alice/midi"
291
292 async def test_get_by_scoped_id_not_found(self, db_session: AsyncSession) -> None:
293 result = await get_domain_by_scoped_id(db_session, "nobody", "missing")
294 assert result is None
295
296 async def test_get_by_id_found(self, db_session: AsyncSession) -> None:
297 d = await _db_domain(db_session, slug="code")
298 await db_session.flush()
299
300 result = await get_domain_by_id(db_session, d.domain_id)
301 assert result is not None
302 assert result.domain_id == d.domain_id
303
304 async def test_get_by_id_not_found(self, db_session: AsyncSession) -> None:
305 result = await get_domain_by_id(db_session, "nonexistent-id")
306 assert result is None
307
308
309 class TestIntegrationListReposForDomain:
310 async def test_returns_public_repos(self, db_session: AsyncSession) -> None:
311 d = await _db_domain(db_session, slug="midi")
312 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
313 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
314 await db_session.flush()
315
316 result = await list_repos_for_domain(db_session, d.domain_id)
317 assert result.total == 2
318 assert len(result.repos) == 2
319
320 async def test_private_repos_excluded(self, db_session: AsyncSession) -> None:
321 d = await _db_domain(db_session, slug="midi")
322 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
323 await _db_repo(db_session, domain_id=d.domain_id, visibility="private")
324 await db_session.flush()
325
326 result = await list_repos_for_domain(db_session, d.domain_id)
327 assert result.total == 1
328
329 async def test_deleted_repos_excluded(self, db_session: AsyncSession) -> None:
330 d = await _db_domain(db_session, slug="midi")
331 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
332 await _db_repo(db_session, domain_id=d.domain_id, visibility="public", deleted=True)
333 await db_session.flush()
334
335 result = await list_repos_for_domain(db_session, d.domain_id)
336 assert result.total == 1
337
338 async def test_nonexistent_domain_returns_empty(self, db_session: AsyncSession) -> None:
339 result = await list_repos_for_domain(db_session, "nonexistent-id")
340 assert result.total == 0
341 assert result.repos == []
342
343
344 class TestIntegrationCreateDomain:
345 async def test_creates_domain_with_hash(self, db_session: AsyncSession) -> None:
346 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
347 result = await create_domain(
348 db_session,
349 author_user_id="alice",
350 author_slug="alice",
351 slug="midi",
352 display_name="MIDI",
353 description="MIDI domain",
354 capabilities=caps,
355 )
356 assert result.domain_id != ""
357 assert result.manifest_hash == compute_manifest_hash(caps)
358
359 async def test_scoped_id_format(self, db_session: AsyncSession) -> None:
360 result = await create_domain(
361 db_session,
362 author_user_id="bob",
363 author_slug="bob",
364 slug="code",
365 display_name="Code",
366 description="",
367 capabilities={},
368 )
369 assert result.scoped_id == "@bob/code"
370
371 async def test_not_verified_by_default(self, db_session: AsyncSession) -> None:
372 result = await create_domain(
373 db_session,
374 author_user_id="alice",
375 author_slug="alice",
376 slug="genome",
377 display_name="Genome",
378 description="",
379 capabilities={},
380 )
381 assert result.is_verified is False
382 assert result.is_deprecated is False
383
384
385 class TestIntegrationRecordDomainInstall:
386 async def test_increments_install_count(self, db_session: AsyncSession) -> None:
387 d = await _db_domain(db_session, slug="midi", install_count=0)
388 await db_session.flush()
389
390 await record_domain_install(db_session, "user1", d.domain_id)
391 await db_session.flush()
392
393 # Verify via get_domain
394 domain = await get_domain_by_id(db_session, d.domain_id)
395 assert domain is not None
396 assert domain.install_count == 1
397
398 async def test_idempotent_same_user(self, db_session: AsyncSession) -> None:
399 d = await _db_domain(db_session, slug="midi", install_count=0)
400 await db_session.flush()
401
402 await record_domain_install(db_session, "user1", d.domain_id)
403 await record_domain_install(db_session, "user1", d.domain_id) # duplicate
404 await db_session.flush()
405
406 domain = await get_domain_by_id(db_session, d.domain_id)
407 assert domain is not None
408 assert domain.install_count == 1 # not 2
409
410 async def test_different_users_each_increment(self, db_session: AsyncSession) -> None:
411 d = await _db_domain(db_session, slug="midi", install_count=0)
412 await db_session.flush()
413
414 await record_domain_install(db_session, "user1", d.domain_id)
415 await record_domain_install(db_session, "user2", d.domain_id)
416 await db_session.flush()
417
418 domain = await get_domain_by_id(db_session, d.domain_id)
419 assert domain is not None
420 assert domain.install_count == 2
421
422
423 # ===========================================================================
424 # Layer 3 — E2E
425 # ===========================================================================
426
427
428 class TestE2EListDomains:
429 async def test_list_returns_200(
430 self,
431 client: AsyncClient,
432 db_session: AsyncSession,
433 ) -> None:
434 await _db_domain(db_session, slug="midi-api")
435 await db_session.commit()
436
437 r = await client.get("/api/domains")
438 assert r.status_code == 200
439 body = r.json()
440 assert "domains" in body
441 assert "total" in body
442 assert isinstance(body["domains"], list)
443
444 async def test_list_no_auth_required(
445 self,
446 client: AsyncClient,
447 db_session: AsyncSession,
448 ) -> None:
449 await db_session.commit()
450 r = await client.get("/api/domains")
451 assert r.status_code == 200
452
453 async def test_list_query_param_filters(
454 self,
455 client: AsyncClient,
456 db_session: AsyncSession,
457 ) -> None:
458 await _db_domain(db_session, slug="piano-e2e", display_name="Piano Roll E2E")
459 await _db_domain(db_session, slug="genome-e2e", display_name="Genome E2E")
460 await db_session.commit()
461
462 r = await client.get("/api/domains?q=Piano+Roll")
463 assert r.status_code == 200
464 body = r.json()
465 slugs = [d["slug"] for d in body["domains"]]
466 assert "piano-e2e" in slugs
467 assert "genome-e2e" not in slugs
468
469 async def test_list_page_size_param(
470 self,
471 client: AsyncClient,
472 db_session: AsyncSession,
473 ) -> None:
474 for i in range(5):
475 await _db_domain(db_session, slug=f"e2e-page-{i}")
476 await db_session.commit()
477
478 r = await client.get("/api/domains?limit=2")
479 assert r.status_code == 200
480 body = r.json()
481 assert len(body["domains"]) <= 2
482 assert body["nextCursor"] is not None
483
484
485 class TestE2ERegisterDomain:
486 async def test_register_201(
487 self,
488 client: AsyncClient,
489 auth_headers: StrDict,
490 db_session: AsyncSession,
491 ) -> None:
492 await db_session.commit()
493 body = {
494 "author_slug": "testuser",
495 "slug": "my-domain",
496 "display_name": "My Domain",
497 "description": "A domain for testing",
498 "capabilities": {"dimensions": [], "merge_semantics": "three_way"},
499 "viewer_type": "generic",
500 "version": "1.0.0",
501 }
502 r = await client.post("/api/domains", json=body, headers=auth_headers)
503 assert r.status_code == 201
504 resp = r.json()
505 assert "domain_id" in resp
506 assert "scoped_id" in resp
507 assert "manifest_hash" in resp
508
509 async def test_register_requires_auth(
510 self,
511 client: AsyncClient,
512 db_session: AsyncSession,
513 ) -> None:
514 await db_session.commit()
515 body = {
516 "author_slug": "testuser",
517 "slug": "unauthed",
518 "display_name": "Unauthed",
519 "description": "",
520 "capabilities": {},
521 }
522 r = await client.post("/api/domains", json=body)
523 assert r.status_code == 401
524
525 async def test_register_duplicate_409(
526 self,
527 client: AsyncClient,
528 auth_headers: StrDict,
529 db_session: AsyncSession,
530 ) -> None:
531 await db_session.commit()
532 body = {
533 "author_slug": "testuser",
534 "slug": "dup-domain",
535 "display_name": "Dup",
536 "description": "",
537 "capabilities": {},
538 }
539 r1 = await client.post("/api/domains", json=body, headers=auth_headers)
540 assert r1.status_code == 201
541
542 r2 = await client.post("/api/domains", json=body, headers=auth_headers)
543 assert r2.status_code == 409
544
545
546 class TestE2EGetDomain:
547 async def test_get_domain_200(
548 self,
549 client: AsyncClient,
550 db_session: AsyncSession,
551 ) -> None:
552 await _db_domain(db_session, author_slug="alice", slug="midi-detail")
553 await db_session.commit()
554
555 r = await client.get("/api/domains/@alice/midi-detail")
556 assert r.status_code == 200
557 body = r.json()
558 assert body["scoped_id"] == "@alice/midi-detail"
559 assert "capabilities" in body
560 assert "manifest_hash" in body
561
562 async def test_get_domain_404(
563 self,
564 client: AsyncClient,
565 ) -> None:
566 r = await client.get("/api/domains/@nobody/nonexistent")
567 assert r.status_code == 404
568
569 async def test_get_domain_repos_200(
570 self,
571 client: AsyncClient,
572 db_session: AsyncSession,
573 ) -> None:
574 d = await _db_domain(db_session, author_slug="alice", slug="midi-repos")
575 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
576 await db_session.commit()
577
578 r = await client.get("/api/domains/@alice/midi-repos/repos")
579 assert r.status_code == 200
580 body = r.json()
581 assert body["total"] == 1
582 assert len(body["repos"]) == 1
583
584 async def test_get_domain_repos_404_unknown_domain(
585 self,
586 client: AsyncClient,
587 ) -> None:
588 r = await client.get("/api/domains/@nobody/missing/repos")
589 assert r.status_code == 404
590
591 async def test_domain_detail_page_json_carries_viewer_type_and_dimensions(
592 self,
593 client: AsyncClient,
594 db_session: AsyncSession,
595 ) -> None:
596 """musehub#117 DOM_10: the UI page must thread viewer_type and
597 dimensions into page_json so the client-side viewer (domain-viewers.ts)
598 can actually render them — not just the API JSON response."""
599 await _db_domain(
600 db_session,
601 author_slug="alice",
602 slug="piano-roll-detail",
603 viewer_type="piano_roll",
604 capabilities={
605 "dimensions": [{"name": "drums", "description": "Percussion track"}],
606 },
607 )
608 await db_session.commit()
609
610 r = await client.get("/domains/@alice/piano-roll-detail")
611 assert r.status_code == 200
612 assert '"viewerType": "piano_roll"' in r.text
613 assert "drums" in r.text
614 assert 'id="dd-viewer-preview"' in r.text
615
616
617 # ===========================================================================
618 # Layer 4 — Stress
619 # ===========================================================================
620
621
622 class TestStress:
623 async def test_list_100_domains(self, db_session: AsyncSession) -> None:
624 for i in range(100):
625 await _db_domain(db_session, slug=f"domain-{i}")
626 await db_session.flush()
627
628 result = await list_domains(db_session, limit=100)
629 assert result.total == 100
630
631 async def test_concurrent_list_domains(self, db_session: AsyncSession) -> None:
632 for i in range(10):
633 await _db_domain(db_session, slug=f"c{i}")
634 await db_session.flush()
635
636 results = await asyncio.gather(
637 *[list_domains(db_session) for _ in range(5)]
638 )
639 assert all(r.total == 10 for r in results)
640
641 async def test_list_repos_for_domain_50_repos(
642 self, db_session: AsyncSession
643 ) -> None:
644 d = await _db_domain(db_session, slug="big-domain")
645 for i in range(50):
646 await _db_repo(db_session, domain_id=d.domain_id, visibility="public")
647 await db_session.flush()
648
649 result = await list_repos_for_domain(db_session, d.domain_id, limit=50)
650 assert result.total == 50
651
652
653 # ===========================================================================
654 # Layer 5 — Data Integrity
655 # ===========================================================================
656
657
658 class TestDataIntegrity:
659 async def test_sorted_by_install_count_desc(self, db_session: AsyncSession) -> None:
660 await _db_domain(db_session, slug="low", install_count=1)
661 await _db_domain(db_session, slug="high", install_count=10)
662 await _db_domain(db_session, slug="mid", install_count=5)
663 await db_session.flush()
664
665 result = await list_domains(db_session)
666 counts = [d.install_count for d in result.domains]
667 assert counts == sorted(counts, reverse=True)
668
669 async def test_deprecated_excluded_from_list(self, db_session: AsyncSession) -> None:
670 await _db_domain(db_session, slug="active")
671 await _db_domain(db_session, slug="old", is_deprecated=True)
672 await db_session.flush()
673
674 result = await list_domains(db_session)
675 slugs = [d.slug for d in result.domains]
676 assert "active" in slugs
677 assert "old" not in slugs
678
679 async def test_manifest_hash_matches_capabilities(
680 self, db_session: AsyncSession
681 ) -> None:
682 caps = {"dimensions": [{"name": "tempo"}], "merge_semantics": "ot"}
683 result = await create_domain(
684 db_session,
685 author_user_id="alice",
686 author_slug="alice",
687 slug="verify-hash",
688 display_name="Verify",
689 description="",
690 capabilities=caps,
691 )
692 assert result.manifest_hash == compute_manifest_hash(caps)
693
694 async def test_capabilities_json_not_lossy(self, db_session: AsyncSession) -> None:
695 caps = {
696 "dimensions": [{"name": "tempo", "unit": "bpm"}],
697 "artifact_types": ["audio/midi"],
698 "merge_semantics": "ot",
699 }
700 created = await create_domain(
701 db_session,
702 author_user_id="alice",
703 author_slug="alice",
704 slug="caps-test",
705 display_name="Caps",
706 description="",
707 capabilities=caps,
708 )
709 await db_session.flush()
710
711 retrieved = await get_domain_by_id(db_session, created.domain_id)
712 assert retrieved is not None
713 assert retrieved.capabilities["dimensions"][0]["name"] == "tempo"
714
715 async def test_total_count_reflects_query_filter(
716 self, db_session: AsyncSession
717 ) -> None:
718 await _db_domain(db_session, slug="match-a", display_name="Match This")
719 await _db_domain(db_session, slug="no-match", display_name="Something Else")
720 await db_session.flush()
721
722 result = await list_domains(db_session, query="Match This")
723 assert result.total == 1
724
725
726 # ===========================================================================
727 # Layer 6 — Security
728 # ===========================================================================
729
730
731 class TestSecurity:
732 async def test_post_without_auth_returns_401(
733 self,
734 client: AsyncClient,
735 db_session: AsyncSession,
736 ) -> None:
737 await db_session.commit()
738 r = await client.post(
739 "/api/domains",
740 json={
741 "author_slug": "hack",
742 "slug": "hack-domain",
743 "display_name": "Hack",
744 "description": "",
745 "capabilities": {},
746 },
747 )
748 assert r.status_code == 401
749
750 async def test_duplicate_scoped_id_returns_409(
751 self,
752 client: AsyncClient,
753 auth_headers: StrDict,
754 db_session: AsyncSession,
755 ) -> None:
756 await db_session.commit()
757 body = {
758 "author_slug": "testuser",
759 "slug": "conflict-test",
760 "display_name": "Conflict",
761 "description": "",
762 "capabilities": {},
763 }
764 r1 = await client.post("/api/domains", json=body, headers=auth_headers)
765 assert r1.status_code == 201
766
767 r2 = await client.post("/api/domains", json=body, headers=auth_headers)
768 assert r2.status_code == 409
769 assert "already registered" in r2.json()["detail"]
770
771 async def test_sql_injection_in_query_param_safe(
772 self,
773 client: AsyncClient,
774 db_session: AsyncSession,
775 ) -> None:
776 await db_session.commit()
777 r = await client.get("/api/domains?q='; DROP TABLE musehub_domains; --")
778 assert r.status_code == 200 # parameterized query — safe
779
780 async def test_manifest_hash_tampering_detectable(self) -> None:
781 """Different capabilities always produce different hashes."""
782 original_caps = {"dimensions": [{"name": "tempo"}]}
783 tampered_caps = {"dimensions": [{"name": "tempo"}, {"name": "injected"}]}
784 assert compute_manifest_hash(original_caps) != compute_manifest_hash(tampered_caps)
785
786 async def test_author_slug_must_match_caller_handle(
787 self,
788 client: AsyncClient,
789 auth_headers: StrDict,
790 db_session: AsyncSession,
791 ) -> None:
792 """musehub#117 DOM_02: registering under someone else's author_slug is impersonation.
793
794 auth_headers authenticates as 'testuser' (see conftest._TEST_HANDLE).
795 Before this fix, author_slug was stored verbatim from the request
796 body with no check against the caller's real authenticated handle —
797 any authenticated user could register '@aaronrene/anything' or
798 '@gabriel/anything' while being neither.
799 """
800 await db_session.commit()
801 body = {
802 "author_slug": "someone-else",
803 "slug": "impersonation-attempt",
804 "display_name": "Not Mine",
805 "description": "",
806 "capabilities": {},
807 }
808 r = await client.post("/api/domains", json=body, headers=auth_headers)
809 assert r.status_code == 403
810 assert "author_slug" in r.json()["detail"].lower() or "handle" in r.json()["detail"].lower()
811
812 async def test_author_slug_matching_own_handle_still_succeeds(
813 self,
814 client: AsyncClient,
815 auth_headers: StrDict,
816 db_session: AsyncSession,
817 ) -> None:
818 """Sanity check: the identity check does not break the legitimate case."""
819 await db_session.commit()
820 body = {
821 "author_slug": "testuser",
822 "slug": "own-domain-after-fix",
823 "display_name": "Mine",
824 "description": "",
825 "capabilities": {},
826 }
827 r = await client.post("/api/domains", json=body, headers=auth_headers)
828 assert r.status_code == 201
829
830
831 # ===========================================================================
832 # Layer 7 — Performance
833 # ===========================================================================
834
835
836 class TestPerformance:
837 async def test_list_50_domains_under_200ms(self, db_session: AsyncSession) -> None:
838 for i in range(50):
839 await _db_domain(db_session, slug=f"perf-{i}")
840 await db_session.flush()
841
842 start = time.perf_counter()
843 result = await list_domains(db_session, limit=50)
844 elapsed = time.perf_counter() - start
845
846 assert result.total == 50
847 assert elapsed < 0.2, f"list_domains took {elapsed:.3f}s"
848
849 async def test_compute_manifest_hash_fast(self) -> None:
850 caps = {"dimensions": [{"name": f"dim_{i}"} for i in range(100)]}
851 start = time.perf_counter()
852 for _ in range(1000):
853 compute_manifest_hash(caps)
854 elapsed = time.perf_counter() - start
855 assert elapsed < 0.5, f"1000 hash computations took {elapsed:.3f}s"
856
857 async def test_create_domain_under_100ms(self, db_session: AsyncSession) -> None:
858 start = time.perf_counter()
859 await create_domain(
860 db_session,
861 author_user_id="alice",
862 author_slug="alice",
863 slug="perf-create",
864 display_name="Perf",
865 description="",
866 capabilities={"dimensions": [], "merge_semantics": "ot"},
867 )
868 elapsed = time.perf_counter() - start
869 assert elapsed < 0.1, f"create_domain took {elapsed:.3f}s"
870
871
872 # ===========================================================================
873 # Layer 8 — Admin: domain verification
874 # ===========================================================================
875
876 _ADMIN_IDENTITY_ID = compute_identity_id(b"adminuser")
877 _ADMIN_HANDLE = "adminuser"
878
879 _ADMIN_CONTEXT = MSignContext(
880 handle=_ADMIN_HANDLE,
881 identity_id=_ADMIN_IDENTITY_ID,
882 is_agent=False,
883 is_admin=True,
884 )
885
886
887 @pytest_asyncio.fixture
888 async def admin_user(db_session: AsyncSession) -> MusehubIdentity:
889 identity = MusehubIdentity(
890 identity_id=_ADMIN_IDENTITY_ID,
891 handle=_ADMIN_HANDLE,
892 display_name="Admin User",
893 identity_type="human",
894 is_admin=True,
895 )
896 db_session.add(identity)
897 await db_session.commit()
898 await db_session.refresh(identity)
899 await db_session.commit()
900 return identity
901
902
903 @pytest.fixture
904 def admin_headers(admin_user: MusehubIdentity) -> Generator[dict[str, str], None, None]:
905 app.dependency_overrides[require_signed_request] = lambda: _ADMIN_CONTEXT
906 app.dependency_overrides[optional_signed_request] = lambda: _ADMIN_CONTEXT
907 yield {"Content-Type": "application/json"}
908 app.dependency_overrides.pop(require_signed_request, None)
909 app.dependency_overrides.pop(optional_signed_request, None)
910
911
912 class TestAdminVerifyDomain:
913 async def test_verify_sets_flag(
914 self,
915 client: AsyncClient,
916 admin_headers: Mapping[str, str],
917 db_session: AsyncSession,
918 ) -> None:
919 await _db_domain(db_session, author_slug="alice", slug="verifiable", is_verified=False)
920 await db_session.commit()
921
922 r = await client.post("/api/domains/@alice/verifiable/verify", headers=admin_headers)
923 assert r.status_code == 200
924 assert r.json()["is_verified"] is True
925
926 async def test_verify_idempotent(
927 self,
928 client: AsyncClient,
929 admin_headers: Mapping[str, str],
930 db_session: AsyncSession,
931 ) -> None:
932 await _db_domain(db_session, author_slug="alice", slug="already-verified", is_verified=True)
933 await db_session.commit()
934
935 r = await client.post("/api/domains/@alice/already-verified/verify", headers=admin_headers)
936 assert r.status_code == 200
937 assert r.json()["is_verified"] is True
938
939 async def test_unverify_clears_flag(
940 self,
941 client: AsyncClient,
942 admin_headers: Mapping[str, str],
943 db_session: AsyncSession,
944 ) -> None:
945 await _db_domain(db_session, author_slug="alice", slug="to-unverify", is_verified=True)
946 await db_session.commit()
947
948 r = await client.delete("/api/domains/@alice/to-unverify/verify", headers=admin_headers)
949 assert r.status_code == 200
950 assert r.json()["is_verified"] is False
951
952 async def test_verify_domain_not_found(
953 self,
954 client: AsyncClient,
955 admin_headers: Mapping[str, str],
956 db_session: AsyncSession,
957 ) -> None:
958 await db_session.commit()
959 r = await client.post("/api/domains/@nobody/ghost/verify", headers=admin_headers)
960 assert r.status_code == 404
961
962 async def test_verify_requires_admin(
963 self,
964 client: AsyncClient,
965 auth_headers: Mapping[str, str],
966 db_session: AsyncSession,
967 ) -> None:
968 await _db_domain(db_session, author_slug="alice", slug="non-admin-target")
969 await db_session.commit()
970
971 r = await client.post("/api/domains/@alice/non-admin-target/verify", headers=auth_headers)
972 assert r.status_code == 403
973
974 async def test_verify_requires_auth(
975 self,
976 client: AsyncClient,
977 db_session: AsyncSession,
978 ) -> None:
979 await _db_domain(db_session, author_slug="alice", slug="unauthed-verify")
980 await db_session.commit()
981
982 r = await client.post("/api/domains/@alice/unauthed-verify/verify")
983 assert r.status_code == 401
984
985
986 # ===========================================================================
987 # musehub#117 DOM_04 — one canonical URL, checked against the source of
988 # truth (the actually-mounted FastAPI route), not a second hardcoded string.
989 # Three places have historically drifted on this single feature: the muse
990 # CLI targeted /api/v1/domains, the public docs claimed /api/musehub/domains,
991 # and the real route was /api/domains the whole time. This guards the
992 # musehub side (route + docs); the CLI side is guarded by
993 # test_publish_targets_api_domains_not_v1 in the muse repo's
994 # tests/test_domains_publish.py.
995 # ===========================================================================
996
997
998 class TestDomainRegistryURLConsistency:
999 def test_register_domain_route_path(self) -> None:
1000 """The register-domain route must be mounted at /api/domains."""
1001 matches = [r for r in app.routes if getattr(r, "name", "") == "register_domain"]
1002 assert len(matches) == 1, "expected exactly one register_domain route"
1003 assert matches[0].path == "/api/domains"
1004
1005 def test_docs_page_references_the_real_route_path(self) -> None:
1006 """docs_muse_domains.html must reference the actual mounted path.
1007
1008 Previously claimed GET /api/musehub/domains — a URL that never
1009 existed on the server.
1010 """
1011 import pathlib
1012 matches = [r for r in app.routes if getattr(r, "name", "") == "register_domain"]
1013 real_path = matches[0].path
1014
1015 docs_path = (
1016 pathlib.Path(__file__).parent.parent
1017 / "musehub" / "templates" / "musehub" / "pages" / "docs_muse_domains.html"
1018 )
1019 content = docs_path.read_text(encoding="utf-8")
1020 assert f"/api/domains" in content and real_path in content
1021 assert "/api/musehub/domains" not in content, (
1022 "stale URL reintroduced — docs must reference the real mounted route"
1023 )
1024 assert "/api/v1/domains" not in content, (
1025 "stale URL reintroduced — docs must reference the real mounted route"
1026 )
1027
1028
1029 # ===========================================================================
1030 # musehub#117 Phase 1 — schema hardening on the publish path (DOM_05..DOM_08)
1031 # ===========================================================================
1032
1033
1034 class TestDomainSchemaHardening:
1035 """DOM_05/DOM_06/DOM_07: viewer_type and capabilities are now real,
1036 schema-validated fields — malformed input gets a specific 422, not a
1037 silent accept or a generic 500 further down the pipeline."""
1038
1039 async def test_invalid_viewer_type_rejected_422(
1040 self,
1041 client: AsyncClient,
1042 auth_headers: StrDict,
1043 db_session: AsyncSession,
1044 ) -> None:
1045 """DOM_05: viewer_type is a fixed enum, not free text."""
1046 await db_session.commit()
1047 body = {
1048 "author_slug": "testuser",
1049 "slug": "bad-viewer-type",
1050 "display_name": "Bad Viewer",
1051 "description": "",
1052 "capabilities": {},
1053 "viewer_type": "not_a_real_viewer",
1054 }
1055 r = await client.post("/api/domains", json=body, headers=auth_headers)
1056 assert r.status_code == 422
1057
1058 async def test_sequence_viewer_rejected_422(
1059 self,
1060 client: AsyncClient,
1061 auth_headers: StrDict,
1062 db_session: AsyncSession,
1063 ) -> None:
1064 """DOM_05: 'sequence_viewer' was documented but never implemented in
1065 the frontend — dropped from the enum rather than kept as a dead
1066 value (see musehub#117 Phase 2)."""
1067 await db_session.commit()
1068 body = {
1069 "author_slug": "testuser",
1070 "slug": "sequence-viewer-attempt",
1071 "display_name": "Sequence",
1072 "description": "",
1073 "capabilities": {},
1074 "viewer_type": "sequence_viewer",
1075 }
1076 r = await client.post("/api/domains", json=body, headers=auth_headers)
1077 assert r.status_code == 422
1078
1079 async def test_valid_viewer_types_accepted(
1080 self,
1081 client: AsyncClient,
1082 auth_headers: StrDict,
1083 db_session: AsyncSession,
1084 ) -> None:
1085 """Sanity check: the real palette values still work."""
1086 await db_session.commit()
1087 for viewer_type in ("symbol_graph", "piano_roll", "generic"):
1088 body = {
1089 "author_slug": "testuser",
1090 "slug": f"viewer-ok-{viewer_type}",
1091 "display_name": "OK",
1092 "description": "",
1093 "capabilities": {},
1094 "viewer_type": viewer_type,
1095 }
1096 r = await client.post("/api/domains", json=body, headers=auth_headers)
1097 assert r.status_code == 201, (viewer_type, r.text)
1098
1099 async def test_invalid_merge_semantics_rejected_422(
1100 self,
1101 client: AsyncClient,
1102 auth_headers: StrDict,
1103 db_session: AsyncSession,
1104 ) -> None:
1105 """DOM_06: merge_semantics is a fixed enum."""
1106 await db_session.commit()
1107 body = {
1108 "author_slug": "testuser",
1109 "slug": "bad-merge-semantics",
1110 "display_name": "Bad",
1111 "description": "",
1112 "capabilities": {"merge_semantics": "yolo"},
1113 }
1114 r = await client.post("/api/domains", json=body, headers=auth_headers)
1115 assert r.status_code == 422
1116
1117 async def test_malformed_dimensions_rejected_422(
1118 self,
1119 client: AsyncClient,
1120 auth_headers: StrDict,
1121 db_session: AsyncSession,
1122 ) -> None:
1123 """DOM_06: a dimension must be {name, description}, not an arbitrary shape."""
1124 await db_session.commit()
1125 body = {
1126 "author_slug": "testuser",
1127 "slug": "bad-dimensions",
1128 "display_name": "Bad",
1129 "description": "",
1130 "capabilities": {"dimensions": ["just-a-string-not-an-object"]},
1131 }
1132 r = await client.post("/api/domains", json=body, headers=auth_headers)
1133 assert r.status_code == 422
1134
1135 async def test_empty_capabilities_still_valid(
1136 self,
1137 client: AsyncClient,
1138 auth_headers: StrDict,
1139 db_session: AsyncSession,
1140 ) -> None:
1141 """Sanity check: an empty manifest (no capabilities declared yet)
1142 must remain valid — all fields default to safe empty values."""
1143 await db_session.commit()
1144 body = {
1145 "author_slug": "testuser",
1146 "slug": "minimal-manifest",
1147 "display_name": "Minimal",
1148 "description": "",
1149 "capabilities": {},
1150 }
1151 r = await client.post("/api/domains", json=body, headers=auth_headers)
1152 assert r.status_code == 201
1153
1154 async def test_too_many_dimensions_rejected_422(
1155 self,
1156 client: AsyncClient,
1157 auth_headers: StrDict,
1158 db_session: AsyncSession,
1159 ) -> None:
1160 """DOM_07: dimension count is capped."""
1161 await db_session.commit()
1162 body = {
1163 "author_slug": "testuser",
1164 "slug": "too-many-dimensions",
1165 "display_name": "Too Many",
1166 "description": "",
1167 "capabilities": {
1168 "dimensions": [{"name": f"dim{i}"} for i in range(51)],
1169 },
1170 }
1171 r = await client.post("/api/domains", json=body, headers=auth_headers)
1172 assert r.status_code == 422
1173
1174 async def test_oversized_capabilities_rejected_422(
1175 self,
1176 client: AsyncClient,
1177 auth_headers: StrDict,
1178 db_session: AsyncSession,
1179 ) -> None:
1180 """DOM_07: total serialized capabilities size is capped at 16 KB."""
1181 await db_session.commit()
1182 body = {
1183 "author_slug": "testuser",
1184 "slug": "oversized-capabilities",
1185 "display_name": "Oversized",
1186 "description": "",
1187 "capabilities": {
1188 "dimensions": [
1189 {"name": f"dim{i}", "description": "x" * 500} for i in range(50)
1190 ],
1191 },
1192 }
1193 r = await client.post("/api/domains", json=body, headers=auth_headers)
1194 assert r.status_code == 422
1195
1196
1197 class TestDomainRegistrationRateLimit:
1198 """DOM_08: POST /api/domains is rate limited — protects the schema-
1199 validated but still-unbounded-frequency publish path from spam/DoS."""
1200
1201 async def test_register_domain_429_after_limit_exceeded(
1202 self,
1203 client: AsyncClient,
1204 auth_headers: StrDict,
1205 db_session: AsyncSession,
1206 ) -> None:
1207 from musehub.rate_limits import DOMAIN_REGISTER_LIMIT
1208 limit_n = int(DOMAIN_REGISTER_LIMIT.split("/")[0])
1209 await db_session.commit()
1210 for i in range(limit_n):
1211 body = {
1212 "author_slug": "testuser",
1213 "slug": f"rate-limit-domain-{i}",
1214 "display_name": "RL",
1215 "description": "",
1216 "capabilities": {},
1217 }
1218 r = await client.post("/api/domains", json=body, headers=auth_headers)
1219 assert r.status_code == 201, r.text
1220
1221 over = await client.post(
1222 "/api/domains",
1223 json={
1224 "author_slug": "testuser",
1225 "slug": "rate-limit-domain-over",
1226 "display_name": "Over",
1227 "description": "",
1228 "capabilities": {},
1229 },
1230 headers=auth_headers,
1231 )
1232 assert over.status_code == 429
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 54 days ago