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