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