gabriel / musehub public
test_mist_advanced.py python
833 lines 33.3 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Advanced mist-domain tests — state integrity, performance, and security gaps.
2
3 Fills the gaps not covered by the eight TDD phase files or the existing
4 test_mists / test_mist_routes / test_mist_security suites:
5
6 State integrity
7 - Indexer idempotency: re-indexing the same commit produces no duplicate
8 history entries (ON CONFLICT DO NOTHING guarantees).
9 - ``persist_intel_results`` upsert: a second call for the same
10 (repo_id, intel_type) replaces the row, not appends.
11 - Version monotonicity: each content update increments version; a
12 metadata-only update (title) leaves version unchanged.
13 - Counter independence: view_count and embed_count are per-mist and do
14 not bleed across rows.
15 - History accumulation: two distinct commits for the same repo produce
16 additive history entries (not replaced).
17 - Manifest with no extractable anchors (binary/markdown only) returns []
18 and writes no history entries.
19
20 Performance
21 - ``build_mist_anchor_index`` on a 5-function file: under 500 ms.
22 - ``MistProvider.compute`` for a repo with a 5-function file: under 1 s.
23 - ``list_mists`` service call across 100 rows: under 500 ms.
24 - ``persist_intel_results`` for 50 result tuples: under 1 s.
25
26 Security (additional scenarios not in test_mist_security.py)
27 - Unauthenticated fork attempt returns 401.
28 - Non-owner fork of a *secret* mist returns 403 or 404.
29 - Non-owner fork of a *public* mist succeeds (fork is public by design).
30 - Corrupted / garbage cursor in list query is silently ignored (no 500).
31 - ``validate_mist_manifest`` with an empty manifest is always valid.
32 - ``validate_mist_manifest`` accumulates errors across multiple bad files.
33 """
34 from __future__ import annotations
35
36 import secrets
37 import time
38 from datetime import datetime, timezone
39
40 import msgpack
41 import pytest
42 from httpx import AsyncClient
43 from muse.core.types import blob_id
44 from sqlalchemy import func, select
45 from sqlalchemy.ext.asyncio import AsyncSession
46
47 from musehub.core.genesis import compute_identity_id, compute_repo_id
48 from musehub.db.musehub_intel_models import MusehubIntelResult, MusehubSymbolHistoryEntry, MusehubSymbolIntel
49 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
50 from musehub.types.json_types import JSONObject, JSONValue, StrDict
51
52
53 # ---------------------------------------------------------------------------
54 # Seed helpers
55 # ---------------------------------------------------------------------------
56
57 def _now() -> datetime:
58 return datetime.now(tz=timezone.utc)
59
60
61 def _oid(raw: bytes) -> str:
62 return blob_id(raw)
63
64
65 def _commit_id() -> str:
66 return blob_id(secrets.token_bytes(16))
67
68
69 def _snap_id(manifest: StrDict) -> str:
70 return blob_id(msgpack.packb(sorted(manifest.items()), use_bin_type=True))
71
72
73 def _manifest_blob(manifest: StrDict) -> bytes:
74 return msgpack.packb(manifest, use_bin_type=True)
75
76
77 async def _seed_repo(
78 session: AsyncSession,
79 owner: str,
80 artifacts: dict[str, bytes],
81 *,
82 visibility: str = "public",
83 ) -> tuple[MusehubRepo, MusehubCommit]:
84 owner_id = compute_identity_id(owner.encode())
85 slug = f"adv-{secrets.token_hex(4)}"
86 created_at = _now()
87 repo_id = compute_repo_id(owner_id, slug, "mist", created_at.isoformat())
88
89 repo = MusehubRepo(
90 repo_id=repo_id,
91 name=slug,
92 owner=owner,
93 slug=slug,
94 visibility=visibility,
95 owner_user_id=owner_id,
96 domain_id="mist",
97 description="advanced test repo",
98 tags=[],
99 created_at=created_at,
100 )
101 session.add(repo)
102 await session.flush()
103
104 manifest: dict[str, str] = {}
105 for filename, raw in artifacts.items():
106 oid = _oid(raw)
107 manifest[filename] = oid
108 if await session.get(MusehubObject, oid) is None:
109 session.add(MusehubObject(
110 object_id=oid,
111 path=filename,
112 size_bytes=len(raw),
113 content_cache=raw,
114 ))
115 await session.flush()
116
117 snap_id = _snap_id(manifest)
118 if await session.get(MusehubSnapshot, snap_id) is None:
119 session.add(MusehubSnapshot(
120 snapshot_id=snap_id,
121 entry_count=len(manifest),
122 manifest_blob=_manifest_blob(manifest),
123 ))
124 session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
125 await session.flush()
126
127 commit_id = _commit_id()
128 commit = MusehubCommit(
129 commit_id=commit_id,
130 message="advanced: initial",
131 author=owner,
132 branch="main",
133 parent_ids=[],
134 snapshot_id=snap_id,
135 timestamp=_now(),
136 )
137 session.add(commit)
138 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
139 await session.flush()
140 return repo, commit
141
142
143 _FIVE_FN_PY = b"def a(): pass\ndef b(): pass\ndef c(): pass\ndef d(): pass\ndef e(): pass\n"
144
145 _OWNER = "testuser"
146
147
148 def _mist_payload(**overrides: JSONValue) -> JSONObject:
149 base: JSONObject = {
150 "filename": f"adv_{secrets.token_hex(4)}.py",
151 "content": f"def fn(): pass\n# {secrets.token_hex(16)}",
152 "visibility": "public",
153 }
154 base.update(overrides)
155 return base
156
157
158 async def _create(client: AsyncClient, headers: StrDict, **overrides: JSONValue) -> JSONObject:
159 r = await client.post("/api/mists", json=_mist_payload(**overrides), headers=headers)
160 assert r.status_code == 201, r.text
161 return dict(r.json())
162
163
164 # ═══════════════════════════════════════════════════════════════════════════
165 # State integrity — indexer idempotency
166 # ═══════════════════════════════════════════════════════════════════════════
167
168 class TestIndexerIdempotency:
169 """Re-indexing the same commit must not create duplicate DB rows."""
170
171 @pytest.mark.asyncio
172 async def test_reindex_same_commit_no_duplicate_history_entries(
173 self, db_session: AsyncSession
174 ) -> None:
175 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
176
177 owner = f"idem_{secrets.token_hex(4)}"
178 repo, commit = await _seed_repo(db_session, owner, {"utils.py": _FIVE_FN_PY})
179
180 await build_mist_anchor_index(db_session, repo.repo_id, commit.commit_id)
181 count_after_first = (await db_session.execute(
182 select(func.count()).where(
183 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
184 )
185 )).scalar_one()
186
187 # Second call — ON CONFLICT DO NOTHING must prevent duplicates.
188 await build_mist_anchor_index(db_session, repo.repo_id, commit.commit_id)
189 count_after_second = (await db_session.execute(
190 select(func.count()).where(
191 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
192 )
193 )).scalar_one()
194
195 assert count_after_first == count_after_second, (
196 "Re-indexing the same commit must not produce duplicate history entries; "
197 f"got {count_after_first} then {count_after_second}"
198 )
199
200 @pytest.mark.asyncio
201 async def test_reindex_same_commit_no_duplicate_intel_rows(
202 self, db_session: AsyncSession
203 ) -> None:
204 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
205
206 owner = f"idem2_{secrets.token_hex(4)}"
207 repo, commit = await _seed_repo(db_session, owner, {"mod.py": _FIVE_FN_PY})
208
209 await build_mist_anchor_index(db_session, repo.repo_id, commit.commit_id)
210 await build_mist_anchor_index(db_session, repo.repo_id, commit.commit_id)
211
212 intel_count = (await db_session.execute(
213 select(func.count()).where(
214 MusehubSymbolIntel.repo_id == repo.repo_id
215 )
216 )).scalar_one()
217 # 5 functions → 5 addresses; each should appear exactly once.
218 assert intel_count == 5, (
219 f"Expected 5 unique symbol intel rows; got {intel_count}"
220 )
221
222 @pytest.mark.asyncio
223 async def test_empty_manifest_returns_empty_list(
224 self, db_session: AsyncSession
225 ) -> None:
226 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
227
228 owner = f"empty_{secrets.token_hex(4)}"
229 # Seed a repo whose commit has an empty snapshot.
230 owner_id = compute_identity_id(owner.encode())
231 slug = f"empty-{secrets.token_hex(3)}"
232 created_at = _now()
233 repo_id = compute_repo_id(owner_id, slug, "mist", created_at.isoformat())
234 repo = MusehubRepo(
235 repo_id=repo_id, name=slug, owner=owner, slug=slug,
236 visibility="public", owner_user_id=owner_id,
237 domain_id="mist", tags=[], created_at=created_at,
238 )
239 db_session.add(repo)
240 await db_session.flush()
241
242 empty_manifest: dict[str, str] = {}
243 snap_id = _snap_id(empty_manifest)
244 db_session.add(MusehubSnapshot(
245 snapshot_id=snap_id,
246 entry_count=0, manifest_blob=_manifest_blob(empty_manifest),
247 ))
248 db_session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
249 await db_session.flush()
250
251 _cid = _commit_id()
252 commit = MusehubCommit(
253 commit_id=_cid, message="empty",
254 author=owner, branch="main", parent_ids=[],
255 snapshot_id=snap_id, timestamp=_now(),
256 )
257 db_session.add(commit)
258 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=_cid))
259 await db_session.flush()
260
261 result = await build_mist_anchor_index(
262 db_session, repo_id, commit.commit_id
263 )
264 assert result == [], "Empty snapshot manifest must return []"
265
266 @pytest.mark.asyncio
267 async def test_no_anchor_artifacts_returns_empty_list(
268 self, db_session: AsyncSession
269 ) -> None:
270 """Manifest containing only files that yield no anchors returns []."""
271 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
272
273 owner = f"noanchor_{secrets.token_hex(4)}"
274 # JSON and YAML yield no symbol anchors.
275 repo, commit = await _seed_repo(
276 db_session, owner,
277 {
278 "config.yaml": b"key: value\nother: 123\n",
279 "schema.json": b'{"type": "object"}',
280 }
281 )
282
283 result = await build_mist_anchor_index(
284 db_session, repo.repo_id, commit.commit_id
285 )
286 assert result == [], (
287 "Manifest with only non-code artifacts (JSON/YAML) must return []"
288 )
289
290 @pytest.mark.asyncio
291 async def test_history_entries_accumulate_across_commits(
292 self, db_session: AsyncSession
293 ) -> None:
294 """A second commit with new anchors adds to history — does not replace."""
295 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
296
297 owner = f"accum_{secrets.token_hex(4)}"
298 repo, commit1 = await _seed_repo(
299 db_session, owner,
300 {"v1.py": b"def first(): pass\n"}
301 )
302 await build_mist_anchor_index(db_session, repo.repo_id, commit1.commit_id)
303
304 count_after_first = (await db_session.execute(
305 select(func.count()).where(
306 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
307 )
308 )).scalar_one()
309
310 # Second commit with a different file.
311 raw2 = b"def second(): pass\ndef third(): pass\n"
312 oid2 = _oid(raw2)
313 if await db_session.get(MusehubObject, oid2) is None:
314 db_session.add(MusehubObject(
315 object_id=oid2, path="v2.py",
316 size_bytes=len(raw2), content_cache=raw2,
317 ))
318 await db_session.flush()
319
320 manifest2 = {"v2.py": oid2}
321 snap2_id = _snap_id(manifest2)
322 if await db_session.get(MusehubSnapshot, snap2_id) is None:
323 db_session.add(MusehubSnapshot(
324 snapshot_id=snap2_id,
325 entry_count=1, manifest_blob=_manifest_blob(manifest2),
326 ))
327 db_session.add(MusehubSnapshotRef(repo_id=repo.repo_id, snapshot_id=snap2_id))
328 await db_session.flush()
329
330 _cid2 = _commit_id()
331 commit2 = MusehubCommit(
332 commit_id=_cid2,
333 message="second commit", author=owner, branch="main",
334 parent_ids=[commit1.commit_id], snapshot_id=snap2_id,
335 timestamp=_now(),
336 )
337 db_session.add(commit2)
338 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=_cid2))
339 await db_session.flush()
340
341 await build_mist_anchor_index(db_session, repo.repo_id, commit2.commit_id)
342
343 count_after_second = (await db_session.execute(
344 select(func.count()).where(
345 MusehubSymbolHistoryEntry.repo_id == repo.repo_id
346 )
347 )).scalar_one()
348
349 assert count_after_second > count_after_first, (
350 "A second commit with new anchors must add history entries; "
351 f"count stayed at {count_after_second}"
352 )
353
354
355 # ═══════════════════════════════════════════════════════════════════════════
356 # State integrity — intel results upsert
357 # ═══════════════════════════════════════════════════════════════════════════
358
359 class TestIntelResultsUpsert:
360 """persist_intel_results must overwrite, not duplicate, on repeated calls."""
361
362 @pytest.mark.asyncio
363 async def test_second_persist_call_overwrites_not_duplicates(
364 self, db_session: AsyncSession
365 ) -> None:
366 from musehub.services.musehub_intel_providers import persist_intel_results
367
368 owner = f"upsert_{secrets.token_hex(4)}"
369 repo, commit = await _seed_repo(db_session, owner, {"x.py": _FIVE_FN_PY})
370
371 results1 = [("mist.anchor_index", {"anchor_count": 3, "filename_count": 1})]
372 await persist_intel_results(db_session, repo.repo_id, commit.commit_id, results1)
373 await db_session.flush()
374
375 results2 = [("mist.anchor_index", {"anchor_count": 5, "filename_count": 1})]
376 await persist_intel_results(db_session, repo.repo_id, commit.commit_id, results2)
377 await db_session.flush()
378
379 rows = (await db_session.execute(
380 select(MusehubIntelResult).where(
381 MusehubIntelResult.repo_id == repo.repo_id,
382 MusehubIntelResult.intel_type == "mist.anchor_index",
383 )
384 )).scalars().all()
385
386 assert len(rows) == 1, (
387 f"Expected exactly 1 intel result row after two upserts; got {len(rows)}"
388 )
389 import json
390 data = json.loads(rows[0].data_json)
391 assert data["anchor_count"] == 5, (
392 "Second persist call must overwrite the first; expected anchor_count=5"
393 )
394
395
396 # ═══════════════════════════════════════════════════════════════════════════
397 # State integrity — CRUD version monotonicity and counter isolation
398 # ═══════════════════════════════════════════════════════════════════════════
399
400 class TestCRUDStateIntegrity:
401 """Version, view_count, and embed_count integrity across mutations."""
402
403 @pytest.mark.asyncio
404 async def test_version_increments_on_each_content_update(
405 self, client: AsyncClient, auth_headers: StrDict
406 ) -> None:
407 mist = await _create(client, auth_headers)
408 mist_id = mist["mistId"]
409 assert mist["version"] == 1
410
411 for expected in range(2, 5):
412 r = await client.patch(
413 f"/api/mists/{mist_id}",
414 json={"content": f"def fn(): return {expected}\n# {secrets.token_hex(16)}"},
415 headers=auth_headers,
416 )
417 assert r.status_code == 200
418 assert r.json()["version"] == expected, (
419 f"Expected version={expected} after update #{expected - 1}; "
420 f"got {r.json()['version']}"
421 )
422
423 @pytest.mark.asyncio
424 async def test_metadata_only_update_does_not_increment_version(
425 self, client: AsyncClient, auth_headers: StrDict
426 ) -> None:
427 mist = await _create(client, auth_headers)
428 mist_id = mist["mistId"]
429 initial_version = mist["version"]
430
431 r = await client.patch(
432 f"/api/mists/{mist_id}",
433 json={"title": "New title", "description": "New description"},
434 headers=auth_headers,
435 )
436 assert r.status_code == 200
437 assert r.json()["version"] == initial_version, (
438 "Metadata-only update must not increment version"
439 )
440
441 @pytest.mark.asyncio
442 async def test_view_count_per_mist_independent(
443 self, client: AsyncClient, auth_headers: StrDict
444 ) -> None:
445 a = await _create(client, auth_headers)
446 b = await _create(client, auth_headers)
447
448 # Hit mist A three times, mist B once.
449 for _ in range(3):
450 await client.get(f"/api/mists/{a['mistId']}")
451 await client.get(f"/api/mists/{b['mistId']}")
452
453 ra = (await client.get(f"/api/mists/{a['mistId']}")).json()
454 rb = (await client.get(f"/api/mists/{b['mistId']}")).json()
455
456 assert ra["viewCount"] >= 3
457 assert ra["viewCount"] != rb["viewCount"], (
458 "view_count must be independent per mist"
459 )
460
461 @pytest.mark.asyncio
462 async def test_embed_count_per_mist_independent(
463 self, client: AsyncClient, auth_headers: StrDict
464 ) -> None:
465 a = await _create(client, auth_headers)
466 b = await _create(client, auth_headers)
467
468 # Embed mist A twice, leave B at zero.
469 for _ in range(2):
470 await client.get(f"/api/{_OWNER}/mists/{a['mistId']}/embed")
471
472 ra = (await client.get(f"/api/mists/{a['mistId']}")).json()
473 rb = (await client.get(f"/api/mists/{b['mistId']}")).json()
474
475 assert ra["embedCount"] >= 2
476 assert rb["embedCount"] == 0 or ra["embedCount"] != rb["embedCount"], (
477 "embed_count must be independent per mist"
478 )
479
480 @pytest.mark.asyncio
481 async def test_deleted_mist_absent_from_list(
482 self, client: AsyncClient, auth_headers: StrDict
483 ) -> None:
484 mist = await _create(client, auth_headers)
485 mist_id = mist["mistId"]
486
487 r_del = await client.delete(f"/api/mists/{mist_id}", headers=auth_headers)
488 assert r_del.status_code == 204
489
490 r_list = await client.get(f"/api/{_OWNER}/mists")
491 assert r_list.status_code == 200
492 ids = [m["mistId"] for m in r_list.json()["mists"]]
493 assert mist_id not in ids, "Deleted mist must not appear in owner list"
494
495 @pytest.mark.asyncio
496 async def test_fork_count_matches_number_of_direct_forks(
497 self, client: AsyncClient, auth_headers: StrDict
498 ) -> None:
499 root = await _create(client, auth_headers)
500 root_id = root["mistId"]
501
502 for _ in range(4):
503 r = await client.post(f"/api/mists/{root_id}/fork", headers=auth_headers)
504 assert r.status_code == 201
505
506 r_root = await client.get(f"/api/mists/{root_id}")
507 assert r_root.json()["forkCount"] == 4
508
509
510 # ═══════════════════════════════════════════════════════════════════════════
511 # Performance
512 # ═══════════════════════════════════════════════════════════════════════════
513
514 class TestPerformance:
515 """Latency assertions for the indexer, provider, and service layer."""
516
517 @pytest.mark.asyncio
518 async def test_build_mist_anchor_index_under_500ms(
519 self, db_session: AsyncSession
520 ) -> None:
521 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
522
523 owner = f"perf1_{secrets.token_hex(4)}"
524 repo, commit = await _seed_repo(db_session, owner, {"perf.py": _FIVE_FN_PY})
525
526 start = time.monotonic()
527 await build_mist_anchor_index(db_session, repo.repo_id, commit.commit_id)
528 elapsed = time.monotonic() - start
529
530 assert elapsed < 0.5, (
531 f"build_mist_anchor_index took {elapsed:.3f}s — expected < 500 ms"
532 )
533
534 @pytest.mark.asyncio
535 async def test_mist_provider_compute_under_1s(
536 self, db_session: AsyncSession
537 ) -> None:
538 from musehub.services.musehub_intel_providers import MistProvider
539
540 owner = f"perf2_{secrets.token_hex(4)}"
541 repo, commit = await _seed_repo(db_session, owner, {"perf.py": _FIVE_FN_PY})
542
543 provider = MistProvider()
544 start = time.monotonic()
545 await provider.compute(db_session, repo.repo_id, commit.commit_id, {})
546 elapsed = time.monotonic() - start
547
548 assert elapsed < 1.0, (
549 f"MistProvider.compute took {elapsed:.3f}s — expected < 1 s"
550 )
551
552 @pytest.mark.asyncio
553 async def test_list_mists_100_rows_under_500ms(
554 self, db_session: AsyncSession
555 ) -> None:
556 from muse.plugins.mist.plugin import compute_mist_id
557 from musehub.services.musehub_mists import create_mist as _svc_create, list_mists
558
559 perf_owner = f"listperf_{secrets.token_hex(4)}"
560 owner_id = compute_identity_id(perf_owner.encode())
561 unique_type = f"lp_{secrets.token_hex(4)}"
562
563 for i in range(100):
564 content = f"# list perf {i} {secrets.token_hex(16)}"
565 mid = compute_mist_id(content.encode())
566 slug = f"lp_{mid}"
567 created_at = _now()
568 repo = MusehubRepo(
569 repo_id=compute_repo_id(owner_id, slug, "mist", created_at.isoformat()),
570 name=slug, owner=perf_owner, slug=slug,
571 visibility="public", owner_user_id=owner_id,
572 created_at=created_at, updated_at=created_at,
573 )
574 db_session.add(repo)
575 await db_session.flush()
576 await _svc_create(
577 db_session, mist_id=mid,
578 filename=f"lp_{i}.py", content=content,
579 owner=perf_owner, repo_id=str(repo.repo_id),
580 artifact_type=unique_type,
581 )
582 await db_session.commit()
583
584 start = time.monotonic()
585 result = await list_mists(
586 db_session, owner=perf_owner, limit=100,
587 )
588 elapsed = time.monotonic() - start
589
590 assert elapsed < 0.5, (
591 f"list_mists(100 rows) took {elapsed:.3f}s — expected < 500 ms"
592 )
593 assert result.total >= 100
594
595 @pytest.mark.asyncio
596 async def test_persist_intel_results_50_tuples_under_1s(
597 self, db_session: AsyncSession
598 ) -> None:
599 from musehub.services.musehub_intel_providers import persist_intel_results
600
601 owner = f"perf3_{secrets.token_hex(4)}"
602 repo, commit = await _seed_repo(db_session, owner, {"x.py": _FIVE_FN_PY})
603
604 results = [
605 (f"mist.perf_type_{i}", {"value": i, "anchor_count": i})
606 for i in range(50)
607 ]
608
609 start = time.monotonic()
610 await persist_intel_results(db_session, repo.repo_id, commit.commit_id, results)
611 await db_session.flush()
612 elapsed = time.monotonic() - start
613
614 assert elapsed < 1.0, (
615 f"persist_intel_results(50 tuples) took {elapsed:.3f}s — expected < 1 s"
616 )
617
618
619 # ═══════════════════════════════════════════════════════════════════════════
620 # Security — additional scenarios
621 # ═══════════════════════════════════════════════════════════════════════════
622
623 class TestAdditionalSecurity:
624 """Scenarios not covered by test_mist_security.py."""
625
626 @pytest.mark.asyncio
627 async def test_unauthenticated_fork_returns_401(
628 self, client: AsyncClient, db_session: AsyncSession
629 ) -> None:
630 """Fork without auth headers must be rejected 401.
631
632 Mist is created directly via service layer so the auth_headers
633 fixture (which injects global dependency overrides) is NOT active.
634 """
635 from muse.plugins.mist.plugin import compute_mist_id
636 from musehub.services.musehub_mists import create_mist as _svc_create
637
638 content = f"unauth_fork {secrets.token_hex(16)}"
639 mid = compute_mist_id(content.encode())
640 owner_id = compute_identity_id(b"testuser")
641 created_at = _now()
642 repo_id = compute_repo_id(owner_id, mid, "mist", created_at.isoformat())
643 repo = MusehubRepo(
644 repo_id=repo_id, name=mid, owner="testuser", slug=mid,
645 visibility="public", owner_user_id=owner_id,
646 created_at=created_at, updated_at=created_at,
647 )
648 db_session.add(repo)
649 await db_session.flush()
650 await _svc_create(
651 db_session, mist_id=mid, filename="f.py", content=content,
652 owner="testuser", repo_id=str(repo_id),
653 )
654 await db_session.commit()
655
656 # No auth_headers fixture active → require_signed_request not overridden.
657 r = await client.post(f"/api/mists/{mid}/fork")
658 assert r.status_code == 401, (
659 f"Unauthenticated fork must return 401; got {r.status_code}"
660 )
661
662 @pytest.mark.asyncio
663 async def test_non_owner_fork_of_secret_mist_blocked(
664 self,
665 client: AsyncClient,
666 auth_headers: StrDict,
667 db_session: AsyncSession,
668 ) -> None:
669 """Non-owner forking a secret mist must be blocked (403 or 404)."""
670 from muse.plugins.mist.plugin import compute_mist_id
671 from musehub.services.musehub_mists import create_mist as _svc_create
672
673 content = f"secret_fork_test {secrets.token_hex(16)}"
674 mid = compute_mist_id(content.encode())
675 other_owner_id = compute_identity_id(b"otheruser")
676 created_at = _now()
677 repo_id = compute_repo_id(other_owner_id, mid, "mist", created_at.isoformat())
678 repo = MusehubRepo(
679 repo_id=repo_id, name=mid, owner="otheruser", slug=mid,
680 visibility="secret", owner_user_id=other_owner_id,
681 created_at=created_at, updated_at=created_at,
682 )
683 db_session.add(repo)
684 await db_session.flush()
685 await _svc_create(
686 db_session, mist_id=mid, filename="secret.py", content=content,
687 owner="otheruser", repo_id=str(repo_id), visibility="secret",
688 )
689 await db_session.commit()
690
691 # auth_headers authenticates as "testuser" — not "otheruser".
692 r = await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
693 assert r.status_code in (403, 404), (
694 f"Non-owner fork of secret mist must be blocked; got {r.status_code}"
695 )
696
697 @pytest.mark.asyncio
698 async def test_non_owner_fork_of_public_mist_succeeds(
699 self,
700 client: AsyncClient,
701 auth_headers: StrDict,
702 db_session: AsyncSession,
703 ) -> None:
704 """Any authenticated user may fork a public mist."""
705 from muse.plugins.mist.plugin import compute_mist_id
706 from musehub.services.musehub_mists import create_mist as _svc_create
707
708 content = f"public_fork_test {secrets.token_hex(16)}"
709 mid = compute_mist_id(content.encode())
710 other_owner_id = compute_identity_id(b"publicowner")
711 created_at = _now()
712 repo_id = compute_repo_id(other_owner_id, mid, "mist", created_at.isoformat())
713 repo = MusehubRepo(
714 repo_id=repo_id, name=mid, owner="publicowner", slug=mid,
715 visibility="public", owner_user_id=other_owner_id,
716 created_at=created_at, updated_at=created_at,
717 )
718 db_session.add(repo)
719 await db_session.flush()
720 await _svc_create(
721 db_session, mist_id=mid, filename="public.py", content=content,
722 owner="publicowner", repo_id=str(repo_id), visibility="public",
723 )
724 await db_session.commit()
725
726 # auth_headers authenticates as "testuser" — not "publicowner".
727 r = await client.post(f"/api/mists/{mid}/fork", headers=auth_headers)
728 assert r.status_code == 201, (
729 f"Authenticated user must be able to fork a public mist; got {r.status_code}"
730 )
731
732 @pytest.mark.asyncio
733 async def test_garbage_cursor_in_list_does_not_crash(
734 self, client: AsyncClient
735 ) -> None:
736 """A corrupted cursor value must be silently ignored (no 500)."""
737 r = await client.get(
738 "/api/mists/explore",
739 params={"cursor": "not-a-valid-iso8601-cursor!!@@##"},
740 )
741 assert r.status_code == 200, (
742 f"Garbage cursor must not cause a 500; got {r.status_code}"
743 )
744
745 @pytest.mark.asyncio
746 async def test_empty_cursor_in_list_treated_as_first_page(
747 self, client: AsyncClient
748 ) -> None:
749 r = await client.get("/api/mists/explore", params={"cursor": ""})
750 assert r.status_code == 200
751
752 def test_validate_mist_manifest_empty_manifest_is_valid(self) -> None:
753 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
754
755 result = validate_mist_manifest({})
756 assert result.valid, "Empty manifest must be valid (nothing to reject)"
757 assert result.errors == []
758 assert result.warnings == []
759
760 def test_validate_mist_manifest_accumulates_all_errors(self) -> None:
761 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
762
763 result = validate_mist_manifest({
764 "../traversal.py": "sha256:aaa",
765 "valid.py": "sha256:bbb",
766 "subdir/nested.py": "sha256:ccc",
767 "null\x00byte.py": "sha256:ddd",
768 })
769 assert not result.valid, "Manifest with multiple bad filenames must be invalid"
770 assert len(result.errors) >= 3, (
771 f"Expected at least 3 errors (one per bad filename); got {result.errors}"
772 )
773
774 def test_validate_mist_manifest_warnings_do_not_block(self) -> None:
775 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
776
777 result = validate_mist_manifest({
778 "data.unknown_ext": "sha256:abc",
779 "noextension": "sha256:def",
780 })
781 assert result.valid, "Unrecognised extensions are warnings, not errors"
782 assert len(result.warnings) >= 1
783
784
785 # ═══════════════════════════════════════════════════════════════════════════
786 # Docstrings — source coverage check
787 # ═══════════════════════════════════════════════════════════════════════════
788
789 class TestDocstrings:
790 """Every public symbol in the mist stack has a docstring."""
791
792 def test_mist_provider_class_has_docstring(self) -> None:
793 from musehub.services.musehub_intel_providers import MistProvider
794 assert MistProvider.__doc__, "MistProvider must have a class docstring"
795
796 def test_mist_provider_compute_has_no_stale_phase_labels(self) -> None:
797 import inspect
798 from musehub.services.musehub_intel_providers import MistProvider
799 src = inspect.getsource(MistProvider.compute)
800 assert "Phase 1:" not in src, "Stale 'Phase 1:' label must be removed"
801 assert "Phase 3:" not in src, "Stale 'Phase 3:' label must be removed"
802
803 def test_profile_snapshot_provider_docstring_says_six_domains(self) -> None:
804 from musehub.services.musehub_intel_providers import ProfileSnapshotProvider
805 doc = ProfileSnapshotProvider.__doc__ or ""
806 assert "6-domain" in doc, (
807 "ProfileSnapshotProvider docstring must say '6-domain' (canvas was updated)"
808 )
809
810 def test_build_mist_anchor_index_has_docstring(self) -> None:
811 from musehub.services.musehub_mist_indexer import build_mist_anchor_index
812 assert build_mist_anchor_index.__doc__
813
814 def test_history_weeks_constant_exported(self) -> None:
815 from musehub.services.musehub_mist_indexer import _HISTORY_WEEKS
816 assert isinstance(_HISTORY_WEEKS, int)
817 assert _HISTORY_WEEKS == 12
818
819 def test_validate_mist_manifest_has_docstring(self) -> None:
820 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
821 assert validate_mist_manifest.__doc__
822
823 def test_mist_validation_result_has_docstring(self) -> None:
824 from musehub.services.musehub_mist_push_validator import MistValidationResult
825 assert MistValidationResult.__doc__
826
827 def test_max_content_bytes_constant_documented(self) -> None:
828 import inspect
829 import musehub.models.mists as _mod
830 src = inspect.getsource(_mod)
831 assert "ContentSizeLimitMiddleware" in src, (
832 "_MAX_CONTENT_BYTES must document that enforcement is via middleware"
833 )
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago