gabriel / musehub public
test_genesis_ids.py python
1,468 lines 61.3 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """TDD: genesis-addressed IDs for every first-class semantic entity.
2
3 CONTRACT (issue #10):
4
5 Every first-class semantic entity in the Muse ecosystem has an identity
6 derived from the minimal immutable facts about the moment it was declared
7 to exist — its genesis context. The formula is universal:
8
9 entity_id = "<algo>:" + hash(NUL.join(genesis_fields)).hexdigest()
10
11 The algorithm prefix is intentionally not hardcoded to "sha256". Any
12 canonical <algo>:<hex> form is valid. This future-proofs the system for
13 hash algorithm upgrades without breaking the validator contract.
14
15 Tier 1 — canonical form
16 All compute_* functions return strings matching ^[a-z][a-z0-9]*:[0-9a-f]{32,}$.
17
18 Tier 2 — determinism
19 Same inputs always produce the same ID. Different inputs always produce
20 different IDs (collision resistance tested with minimal single-field diffs).
21
22 Tier 3 — separator injection safety
23 Field values containing NUL bytes, pipe chars, colons, or path separators
24 do not break the hash or allow crafted collisions.
25
26 Tier 3b — Pydantic boundary enforcement
27 Wire-protocol and response models reject non-canonical IDs at the
28 Pydantic validation boundary. Both request models (untrusted input) and
29 response models (service layer bug detection) are covered. Optional ID
30 fields accept None and valid canonical IDs, reject malformed strings.
31
32 Tier 4 — cross-verification (CLI ↔ hub)
33 Functions that exist in both muse.core.genesis and musehub.core.genesis
34 produce identical output for the same inputs.
35
36 Tier 5 — derivation chain
37 Entity IDs that take other entity IDs as genesis fields (e.g. issue_id
38 takes repo_id) form a verifiable chain: changing the parent ID changes
39 all descendant IDs.
40 """
41
42 from __future__ import annotations
43
44 import re
45 import sys
46 from datetime import datetime, timezone
47 from pathlib import Path
48
49 import pytest
50 from muse.core.types import fake_id
51 from pydantic import ValidationError
52
53 from musehub.models.musehub import (
54 CreateRepoRequest,
55 IssueCommentCreate,
56 ProposalCommentCreate,
57 ProposalReviewResponse,
58 ReleaseAssetResponse,
59 UserForkedRepoEntry,
60 WebhookResponse,
61 WireTagInput,
62 )
63 from musehub.api.routes.musehub.collaborators import CollaboratorResponse
64 from musehub.api.routes.musehub.labels import LabelResponse
65 from musehub.models.wire import (
66 WireCommit,
67 WireFetchRequest,
68 WireNegotiateRequest,
69 WireObject,
70 WireSnapshot,
71 )
72
73 # Hub-side genesis functions
74 from musehub.core.genesis import (
75 compute_asset_id,
76 compute_bridge_mirror_id,
77 compute_collaborator_id,
78 compute_comment_id,
79 compute_domain_id,
80 compute_domain_install_id,
81 compute_fork_id,
82 compute_identity_id,
83 compute_issue_event_id,
84 compute_issue_id,
85 compute_job_id,
86 compute_key_id,
87 compute_label_id,
88 compute_mist_id,
89 compute_proposal_id,
90 compute_release_id,
91 compute_repo_id,
92 compute_reservation_id,
93 compute_review_id,
94 compute_session_id,
95 compute_tag_id,
96 compute_task_id,
97 compute_webhook_delivery_id,
98 compute_webhook_id,
99 mist_short_id,
100 )
101
102 # CLI-side genesis functions (cross-verification) — skip gracefully if not yet present
103 sys.path.insert(0, str(Path.home() / "ecosystem" / "muse"))
104 try:
105 from muse.core.genesis import (
106 compute_release_id as cli_compute_release_id,
107 compute_tag_id as cli_compute_tag_id,
108 )
109 _CLI_GENESIS_AVAILABLE = True
110 except ModuleNotFoundError:
111 _CLI_GENESIS_AVAILABLE = False
112 cli_compute_release_id = None # type: ignore[assignment]
113 cli_compute_tag_id = None # type: ignore[assignment]
114
115 # Algo-agnostic canonical pattern: <lowercase-algo>:<lowercase-hex, ≥32 chars>
116 # Do NOT tighten to "sha256" only — this pattern must survive hash algorithm upgrades.
117 _CANONICAL_RE = re.compile(r"^[a-z][a-z0-9]*:[0-9a-f]{32,}$")
118
119 # ---------------------------------------------------------------------------
120 # Shared deterministic inputs
121 # ---------------------------------------------------------------------------
122
123 _PUBKEY = bytes(range(32)) # 32 deterministic bytes
124 _IDENTITY_ID = compute_identity_id(_PUBKEY)
125
126 _REPO_ID = compute_repo_id(_IDENTITY_ID, "my-repo", "code", "2026-01-01T00:00:00Z")
127 _ISSUE_ID = compute_issue_id(_REPO_ID, _IDENTITY_ID, "2026-01-02T00:00:00Z")
128 _PROPOSAL_ID = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/x", "main", "2026-01-03T00:00:00Z")
129 _RELEASE_ID = compute_release_id(_REPO_ID, "v1.0.0", "2026-01-04T00:00:00Z")
130 _COMMIT_ID = fake_id("commit-stub") # canonical stub; real commits use compute_commit_id
131 _TAG_ID = compute_tag_id(_REPO_ID, _COMMIT_ID, "emotion:joyful", "2026-01-05T00:00:00Z")
132 _SESSION_ID = compute_session_id(_REPO_ID, _IDENTITY_ID, "2026-01-06T00:00:00Z")
133 _MIST_ID = compute_mist_id(b"hello muse")
134 _COMMENT_ID = compute_comment_id(_ISSUE_ID, _IDENTITY_ID, "2026-01-07T00:00:00Z")
135 _REVIEW_ID = compute_review_id(_PROPOSAL_ID, _IDENTITY_ID, "2026-01-08T00:00:00Z")
136
137 # Phase 2 genesis IDs
138 _LABEL_ID = compute_label_id(_REPO_ID, "bug", "2026-01-09T00:00:00Z")
139 _ASSET_ID = compute_asset_id(_RELEASE_ID, "v1.0.0-linux-amd64.tar.gz", "2026-01-10T00:00:00Z")
140 _WEBHOOK_ID = compute_webhook_id(_REPO_ID, "https://ci.example.com/hook", "2026-01-11T00:00:00Z")
141 _FORK_REPO_ID = compute_repo_id(_IDENTITY_ID, "my-fork", "code", "2026-01-12T00:00:00Z")
142 _FORK_ID = compute_fork_id(_REPO_ID, _FORK_REPO_ID, "2026-01-13T00:00:00Z")
143 _COLLAB_IDENTITY_ID = compute_identity_id(bytes(range(1, 33)))
144 _COLLABORATOR_ID = compute_collaborator_id(_REPO_ID, _COLLAB_IDENTITY_ID, "2026-01-14T00:00:00Z")
145 _KEY_ID = compute_key_id(_IDENTITY_ID, "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
146 _DOMAIN_ID = compute_domain_id("gabriel", "code", "2026-01-15T00:00:00Z")
147
148 # Previously random-ID entities — now genesis-addressed
149 _BRIDGE_MIRROR_ID = compute_bridge_mirror_id(_REPO_ID, "https://github.com/gabriel/my-repo.git")
150 _DOMAIN_INSTALL_ID = compute_domain_install_id(_IDENTITY_ID, _DOMAIN_ID)
151 _ISSUE_EVENT_ID = compute_issue_event_id(_ISSUE_ID, "opened", "gabriel", "2026-01-16T00:00:00Z")
152 _JOB_ID = compute_job_id(_REPO_ID, "intel.code", "2026-01-17T00:00:00Z")
153 _WEBHOOK_DELIVERY_ID = compute_webhook_delivery_id(_WEBHOOK_ID, "push", 1, "2026-01-18T00:00:00Z")
154
155 # Coord entities — genesis-addressed (content-addressed, not random)
156 _TASK_ID = compute_task_id(_REPO_ID, "default", "agent-1", "2026-01-19T00:00:00Z")
157 _RESERVATION_ID = compute_reservation_id(
158 _REPO_ID, "agent-1",
159 ",".join(sorted(["src/engine.py::AudioEngine", "src/mixer.py::Mixer"])),
160 "2026-01-20T00:00:00Z",
161 )
162
163
164 # ===========================================================================
165 # Tier 1 — canonical form
166 # ===========================================================================
167
168 class TestCanonicalForm:
169
170 def test_identity_id_canonical(self) -> None:
171 assert _CANONICAL_RE.match(_IDENTITY_ID)
172
173 def test_repo_id_canonical(self) -> None:
174 assert _CANONICAL_RE.match(_REPO_ID)
175
176 def test_issue_id_canonical(self) -> None:
177 assert _CANONICAL_RE.match(_ISSUE_ID)
178
179 def test_proposal_id_canonical(self) -> None:
180 assert _CANONICAL_RE.match(_PROPOSAL_ID)
181
182 def test_release_id_canonical(self) -> None:
183 assert _CANONICAL_RE.match(_RELEASE_ID)
184
185 def test_tag_id_canonical(self) -> None:
186 assert _CANONICAL_RE.match(_TAG_ID)
187
188 def test_session_id_canonical(self) -> None:
189 assert _CANONICAL_RE.match(_SESSION_ID)
190
191 def test_mist_id_canonical(self) -> None:
192 assert _CANONICAL_RE.match(_MIST_ID)
193
194 def test_comment_id_canonical(self) -> None:
195 assert _CANONICAL_RE.match(_COMMENT_ID)
196
197 def test_review_id_canonical(self) -> None:
198 assert _CANONICAL_RE.match(_REVIEW_ID)
199
200 def test_mist_short_id_is_12_chars(self) -> None:
201 short = mist_short_id(_MIST_ID)
202 assert len(short) == 12
203 assert re.match(r"^[0-9a-f]{12}$", short)
204
205 def test_mist_short_id_is_prefix_of_digest(self) -> None:
206 digest = _MIST_ID.removeprefix("sha256:")
207 assert mist_short_id(_MIST_ID) == digest[:12]
208
209 def test_bridge_mirror_id_canonical(self) -> None:
210 assert _CANONICAL_RE.match(_BRIDGE_MIRROR_ID)
211
212 def test_domain_install_id_canonical(self) -> None:
213 assert _CANONICAL_RE.match(_DOMAIN_INSTALL_ID)
214
215 def test_issue_event_id_canonical(self) -> None:
216 assert _CANONICAL_RE.match(_ISSUE_EVENT_ID)
217
218 def test_job_id_canonical(self) -> None:
219 assert _CANONICAL_RE.match(_JOB_ID)
220
221 def test_webhook_delivery_id_canonical(self) -> None:
222 assert _CANONICAL_RE.match(_WEBHOOK_DELIVERY_ID)
223
224 def test_task_id_canonical(self) -> None:
225 assert _CANONICAL_RE.match(_TASK_ID)
226
227 def test_reservation_id_canonical(self) -> None:
228 assert _CANONICAL_RE.match(_RESERVATION_ID)
229
230
231 # ===========================================================================
232 # Tier 2 — determinism and collision resistance
233 # ===========================================================================
234
235 class TestDeterminism:
236
237 def test_identity_id_deterministic(self) -> None:
238 assert compute_identity_id(_PUBKEY) == compute_identity_id(_PUBKEY)
239
240 def test_repo_id_deterministic(self) -> None:
241 args = (_IDENTITY_ID, "repo", "code", "2026-01-01T00:00:00Z")
242 assert compute_repo_id(*args) == compute_repo_id(*args)
243
244 def test_issue_id_deterministic(self) -> None:
245 args = (_REPO_ID, _IDENTITY_ID, "2026-01-02T00:00:00Z")
246 assert compute_issue_id(*args) == compute_issue_id(*args)
247
248 def test_different_pubkeys_yield_different_identity_ids(self) -> None:
249 pk_a = bytes(range(32))
250 pk_b = bytes(range(1, 33))
251 assert compute_identity_id(pk_a) != compute_identity_id(pk_b)
252
253 def test_different_slugs_yield_different_repo_ids(self) -> None:
254 a = compute_repo_id(_IDENTITY_ID, "repo-a", "code", "2026-01-01T00:00:00Z")
255 b = compute_repo_id(_IDENTITY_ID, "repo-b", "code", "2026-01-01T00:00:00Z")
256 assert a != b
257
258 def test_different_domains_yield_different_repo_ids(self) -> None:
259 a = compute_repo_id(_IDENTITY_ID, "repo", "code", "2026-01-01T00:00:00Z")
260 b = compute_repo_id(_IDENTITY_ID, "repo", "music", "2026-01-01T00:00:00Z")
261 assert a != b
262
263 def test_different_timestamps_yield_different_issue_ids(self) -> None:
264 a = compute_issue_id(_REPO_ID, _IDENTITY_ID, "2026-01-01T00:00:00Z")
265 b = compute_issue_id(_REPO_ID, _IDENTITY_ID, "2026-01-02T00:00:00Z")
266 assert a != b
267
268 def test_different_branches_yield_different_proposal_ids(self) -> None:
269 a = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/a", "main", "2026-01-01T00:00:00Z")
270 b = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/b", "main", "2026-01-01T00:00:00Z")
271 assert a != b
272
273 def test_different_tags_yield_different_release_ids(self) -> None:
274 a = compute_release_id(_REPO_ID, "v1.0.0", "2026-01-01T00:00:00Z")
275 b = compute_release_id(_REPO_ID, "v2.0.0", "2026-01-01T00:00:00Z")
276 assert a != b
277
278 def test_different_labels_yield_different_tag_ids(self) -> None:
279 a = compute_tag_id(_REPO_ID, _COMMIT_ID, "emotion:joyful", "2026-01-01T00:00:00Z")
280 b = compute_tag_id(_REPO_ID, _COMMIT_ID, "emotion:melancholic", "2026-01-01T00:00:00Z")
281 assert a != b
282
283 def test_different_content_yields_different_mist_ids(self) -> None:
284 assert compute_mist_id(b"hello") != compute_mist_id(b"world")
285
286 def test_all_entity_ids_are_distinct(self) -> None:
287 """All entity IDs computed from their respective genesis contexts are unique."""
288 ids = [
289 _IDENTITY_ID, _REPO_ID, _ISSUE_ID, _PROPOSAL_ID,
290 _RELEASE_ID, _TAG_ID, _SESSION_ID, _MIST_ID,
291 _COMMENT_ID, _REVIEW_ID,
292 ]
293 assert len(ids) == len(set(ids)), "two entity IDs collided"
294
295
296 # ===========================================================================
297 # Tier 3 — separator injection safety
298 # ===========================================================================
299
300 class TestSeparatorInjection:
301
302 def test_nul_byte_in_slug_does_not_collide(self) -> None:
303 """A slug containing NUL + domain cannot be crafted to match a different (slug, domain) pair."""
304 # If the separator were not NUL, "a|b" + "|" + "c" could equal "a" + "|" + "b|c".
305 # With NUL separator, NUL inside a field value is structurally impossible in normal usage,
306 # but we verify the function still returns a valid ID for unusual inputs.
307 exotic = compute_repo_id(_IDENTITY_ID, "repo\x00extra", "code", "2026-01-01T00:00:00Z")
308 normal = compute_repo_id(_IDENTITY_ID, "repo", "code\x00extra", "2026-01-01T00:00:00Z")
309 assert _CANONICAL_RE.match(exotic)
310 assert exotic != normal, "NUL in field value must not produce collisions across fields"
311
312 def test_pipe_in_label_is_safe(self) -> None:
313 a = compute_tag_id(_REPO_ID, _COMMIT_ID, "section|verse", "2026-01-01T00:00:00Z")
314 assert _CANONICAL_RE.match(a)
315
316 def test_colon_in_label_is_safe(self) -> None:
317 a = compute_tag_id(_REPO_ID, _COMMIT_ID, "emotion:joyful:extra", "2026-01-01T00:00:00Z")
318 assert _CANONICAL_RE.match(a)
319
320 def test_path_separator_in_branch_is_safe(self) -> None:
321 a = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/nested/branch", "main", "2026-01-01T00:00:00Z")
322 assert _CANONICAL_RE.match(a)
323
324 def test_sha256_prefix_in_field_is_safe(self) -> None:
325 """Entity IDs used as genesis fields (which start with sha256:) are handled correctly."""
326 # repo_id and identity_id both start with "sha256:" — verify no double-prefix or truncation.
327 repo = compute_repo_id(_IDENTITY_ID, "test", "code", "2026-01-01T00:00:00Z")
328 issue = compute_issue_id(repo, _IDENTITY_ID, "2026-01-01T00:00:00Z")
329 assert _CANONICAL_RE.match(issue)
330
331
332 # ===========================================================================
333 # Tier 3b — Pydantic boundary enforcement
334 # ===========================================================================
335
336 _VALID_ID = fake_id("valid-id-stub")
337 _FUTURE_ID = "blake3:" + "b" * 64 # algo-agnostic: must also be accepted
338 _BAD_IDS = [
339 "not-a-sha",
340 "a1b2c3d4-e5f6-7890-abcd-ef1234567890", # plain string, not sha256:
341 f"SHA256:{'a' * 64}", # uppercase algo
342 f"sha256:{'A' * 64}", # uppercase hex
343 "",
344 "sha256:tooshort",
345 ]
346 _DT = datetime(2026, 1, 1, tzinfo=timezone.utc)
347
348
349 class TestPydanticBoundaryWireModels:
350 """Wire models reject non-canonical IDs; accept valid canonical forms."""
351
352 def test_wire_commit_rejects_bad_commit_id(self) -> None:
353 for bad in _BAD_IDS:
354 with pytest.raises(ValidationError):
355 WireCommit(commit_id=bad)
356
357 def test_wire_commit_accepts_future_algo(self) -> None:
358 c = WireCommit(commit_id=_FUTURE_ID)
359 assert c.commit_id == _FUTURE_ID
360
361 def test_wire_snapshot_rejects_bad_id(self) -> None:
362 with pytest.raises(ValidationError):
363 WireSnapshot(snapshot_id="bad-id")
364
365 def test_wire_snapshot_accepts_future_algo(self) -> None:
366 s = WireSnapshot(snapshot_id=_FUTURE_ID)
367 assert s.snapshot_id == _FUTURE_ID
368
369 def test_wire_object_rejects_bad_object_id(self) -> None:
370 with pytest.raises(ValidationError):
371 WireObject(object_id="bad", content=b"x")
372
373 def test_wire_object_accepts_future_algo(self) -> None:
374 o = WireObject(object_id=_FUTURE_ID, content=b"x")
375 assert o.object_id == _FUTURE_ID
376
377 def test_fetch_request_rejects_bad_want(self) -> None:
378 with pytest.raises(ValidationError):
379 WireFetchRequest(want=["not-valid"], have=[])
380
381 def test_fetch_request_rejects_bad_have(self) -> None:
382 with pytest.raises(ValidationError):
383 WireFetchRequest(want=[], have=["not-a-content-id"])
384
385 def test_fetch_request_accepts_valid_and_future_ids(self) -> None:
386 r = WireFetchRequest(want=[_VALID_ID, _FUTURE_ID], have=[_VALID_ID])
387 assert len(r.want) == 2
388
389 def test_negotiate_request_rejects_bad_have(self) -> None:
390 with pytest.raises(ValidationError):
391 WireNegotiateRequest(have=["bad"], want=[])
392
393 def test_negotiate_request_rejects_bad_want(self) -> None:
394 with pytest.raises(ValidationError):
395 WireNegotiateRequest(have=[], want=["bad"])
396
397 def test_negotiate_request_accepts_future_algo(self) -> None:
398 r = WireNegotiateRequest(have=[_FUTURE_ID], want=[_FUTURE_ID])
399 assert r.have == [_FUTURE_ID]
400
401
402 class TestPydanticBoundaryResponseModels:
403 """Response models reject non-canonical IDs (catches service layer bugs)."""
404
405 def test_proposal_review_rejects_bad_id(self) -> None:
406 with pytest.raises(ValidationError):
407 ProposalReviewResponse(
408 id="bad-id", proposal_id=_VALID_ID,
409 reviewer_username="gabriel", state="approved", created_at=_DT,
410 )
411
412 def test_proposal_review_rejects_bad_proposal_id(self) -> None:
413 with pytest.raises(ValidationError):
414 ProposalReviewResponse(
415 id=_VALID_ID, proposal_id="not-canonical",
416 reviewer_username="gabriel", state="approved", created_at=_DT,
417 )
418
419 def test_proposal_review_accepts_future_algo(self) -> None:
420 r = ProposalReviewResponse(
421 id=_FUTURE_ID, proposal_id=_FUTURE_ID,
422 reviewer_username="gabriel", state="approved", created_at=_DT,
423 )
424 assert r.id == _FUTURE_ID
425
426 def test_release_asset_rejects_bad_asset_id(self) -> None:
427 with pytest.raises(ValidationError):
428 ReleaseAssetResponse(
429 asset_id="bad", release_id=_VALID_ID,
430 name="f.tar.gz", download_url="https://x.com/f", created_at=_DT,
431 )
432
433 def test_release_asset_rejects_bad_release_id(self) -> None:
434 with pytest.raises(ValidationError):
435 ReleaseAssetResponse(
436 asset_id=_VALID_ID, release_id="not-an-id",
437 name="f.tar.gz", download_url="https://x.com/f", created_at=_DT,
438 )
439
440 def test_release_asset_accepts_future_algo(self) -> None:
441 a = ReleaseAssetResponse(
442 asset_id=_FUTURE_ID, release_id=_FUTURE_ID,
443 name="f.tar.gz", download_url="https://x.com/f", created_at=_DT,
444 )
445 assert a.release_id == _FUTURE_ID
446
447 def test_wire_tag_rejects_bad_tag_id(self) -> None:
448 with pytest.raises(ValidationError):
449 WireTagInput(tag_id="bad", commit_id=_VALID_ID, tag="section:verse")
450
451 def test_wire_tag_rejects_bad_commit_id(self) -> None:
452 with pytest.raises(ValidationError):
453 WireTagInput(tag_id=_VALID_ID, commit_id="bad", tag="section:verse")
454
455 def test_wire_tag_accepts_future_algo(self) -> None:
456 t = WireTagInput(tag_id=_FUTURE_ID, commit_id=_FUTURE_ID, tag="section:verse")
457 assert t.tag_id == _FUTURE_ID
458
459
460 class TestPydanticBoundaryOptionalFields:
461 """Optional genesis ID fields accept None, valid IDs, reject bad strings."""
462
463 def test_create_repo_template_none(self) -> None:
464 r = CreateRepoRequest(name="muse", owner="gabriel")
465 assert r.template_repo_id is None
466
467 def test_create_repo_template_valid(self) -> None:
468 r = CreateRepoRequest(name="muse", owner="gabriel", template_repo_id=_VALID_ID)
469 assert r.template_repo_id == _VALID_ID
470
471 def test_create_repo_template_future_algo(self) -> None:
472 r = CreateRepoRequest(name="muse", owner="gabriel", template_repo_id=_FUTURE_ID)
473 assert r.template_repo_id == _FUTURE_ID
474
475 def test_create_repo_template_bad(self) -> None:
476 with pytest.raises(ValidationError):
477 CreateRepoRequest(name="muse", owner="gabriel", template_repo_id="bad-format")
478
479 def test_issue_comment_parent_none(self) -> None:
480 assert IssueCommentCreate(body="hi").parent_id is None
481
482 def test_issue_comment_parent_valid(self) -> None:
483 c = IssueCommentCreate(body="reply", parent_id=_VALID_ID)
484 assert c.parent_id == _VALID_ID
485
486 def test_issue_comment_parent_future_algo(self) -> None:
487 c = IssueCommentCreate(body="reply", parent_id=_FUTURE_ID)
488 assert c.parent_id == _FUTURE_ID
489
490 def test_issue_comment_parent_bad(self) -> None:
491 with pytest.raises(ValidationError):
492 IssueCommentCreate(body="reply", parent_id="a1b2c3d4-bad")
493
494 def test_proposal_comment_parent_none(self) -> None:
495 assert ProposalCommentCreate(body="hi").parent_comment_id is None
496
497 def test_proposal_comment_parent_valid(self) -> None:
498 c = ProposalCommentCreate(body="reply", parent_comment_id=_VALID_ID)
499 assert c.parent_comment_id == _VALID_ID
500
501 def test_proposal_comment_parent_future_algo(self) -> None:
502 c = ProposalCommentCreate(body="reply", parent_comment_id=_FUTURE_ID)
503 assert c.parent_comment_id == _FUTURE_ID
504
505 def test_proposal_comment_parent_bad_format(self) -> None:
506 with pytest.raises(ValidationError):
507 ProposalCommentCreate(
508 body="reply",
509 parent_comment_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
510 )
511
512
513 # ===========================================================================
514 # Tier 4 — cross-verification (CLI ↔ hub)
515 # ===========================================================================
516
517 @pytest.mark.skipif(not _CLI_GENESIS_AVAILABLE, reason="muse.core.genesis not yet implemented in CLI")
518 class TestCrossVerification:
519
520 def test_tag_id_cli_and_hub_agree(self) -> None:
521 args = (_REPO_ID, _COMMIT_ID, "emotion:joyful", "2026-01-01T00:00:00Z")
522 assert compute_tag_id(*args) == cli_compute_tag_id(*args)
523
524 def test_release_id_cli_and_hub_agree(self) -> None:
525 args = (_REPO_ID, "v1.0.0", "2026-01-01T00:00:00Z")
526 assert compute_release_id(*args) == cli_compute_release_id(*args)
527
528 def test_tag_id_cli_returns_canonical(self) -> None:
529 result = cli_compute_tag_id(_REPO_ID, _COMMIT_ID, "v1.0-wip", "2026-01-01T00:00:00Z")
530 assert _CANONICAL_RE.match(result)
531
532 def test_release_id_cli_returns_canonical(self) -> None:
533 result = cli_compute_release_id(_REPO_ID, "v2.0.0", "2026-01-01T00:00:00Z")
534 assert _CANONICAL_RE.match(result)
535
536
537 # ===========================================================================
538 # Tier 5 — derivation chain
539 # ===========================================================================
540
541 class TestDerivationChain:
542
543 def test_changing_owner_changes_repo_id(self) -> None:
544 id_a = compute_identity_id(bytes(range(32)))
545 id_b = compute_identity_id(bytes(range(1, 33)))
546 repo_a = compute_repo_id(id_a, "repo", "code", "2026-01-01T00:00:00Z")
547 repo_b = compute_repo_id(id_b, "repo", "code", "2026-01-01T00:00:00Z")
548 assert repo_a != repo_b
549
550 def test_changing_repo_changes_issue_id(self) -> None:
551 repo_a = compute_repo_id(_IDENTITY_ID, "repo-a", "code", "2026-01-01T00:00:00Z")
552 repo_b = compute_repo_id(_IDENTITY_ID, "repo-b", "code", "2026-01-01T00:00:00Z")
553 issue_a = compute_issue_id(repo_a, _IDENTITY_ID, "2026-01-02T00:00:00Z")
554 issue_b = compute_issue_id(repo_b, _IDENTITY_ID, "2026-01-02T00:00:00Z")
555 assert issue_a != issue_b
556
557 def test_changing_issue_changes_comment_id(self) -> None:
558 issue_a = compute_issue_id(_REPO_ID, _IDENTITY_ID, "2026-01-01T00:00:00Z")
559 issue_b = compute_issue_id(_REPO_ID, _IDENTITY_ID, "2026-01-02T00:00:00Z")
560 comment_a = compute_comment_id(issue_a, _IDENTITY_ID, "2026-01-03T00:00:00Z")
561 comment_b = compute_comment_id(issue_b, _IDENTITY_ID, "2026-01-03T00:00:00Z")
562 assert comment_a != comment_b
563
564 def test_changing_proposal_changes_review_id(self) -> None:
565 prop_a = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/a", "main", "2026-01-01T00:00:00Z")
566 prop_b = compute_proposal_id(_REPO_ID, _IDENTITY_ID, "feat/b", "main", "2026-01-01T00:00:00Z")
567 review_a = compute_review_id(prop_a, _IDENTITY_ID, "2026-01-02T00:00:00Z")
568 review_b = compute_review_id(prop_b, _IDENTITY_ID, "2026-01-02T00:00:00Z")
569 assert review_a != review_b
570
571 def test_identity_repo_issue_comment_chain_is_fully_verifiable(self) -> None:
572 """Full four-level chain: identity → repo → issue → comment."""
573 pk = bytes(range(32))
574 identity_id = compute_identity_id(pk)
575 repo_id = compute_repo_id(identity_id, "chain-test", "code", "2026-01-01T00:00:00Z")
576 issue_id = compute_issue_id(repo_id, identity_id, "2026-01-02T00:00:00Z")
577 comment_id = compute_comment_id(issue_id, identity_id, "2026-01-03T00:00:00Z")
578
579 # Every level is canonical
580 for entity_id in (identity_id, repo_id, issue_id, comment_id):
581 assert _CANONICAL_RE.match(entity_id), f"non-canonical: {entity_id}"
582
583 # Mutating the pubkey propagates through the entire chain
584 pk2 = bytes(range(1, 33))
585 identity_id2 = compute_identity_id(pk2)
586 repo_id2 = compute_repo_id(identity_id2, "chain-test", "code", "2026-01-01T00:00:00Z")
587 issue_id2 = compute_issue_id(repo_id2, identity_id2, "2026-01-02T00:00:00Z")
588 comment_id2 = compute_comment_id(issue_id2, identity_id2, "2026-01-03T00:00:00Z")
589
590 assert identity_id != identity_id2
591 assert repo_id != repo_id2
592 assert issue_id != issue_id2
593 assert comment_id != comment_id2
594
595
596 # ===========================================================================
597 # Tier 6 — service-layer contract (unit, mocked DB)
598 #
599 # Every service function that creates a first-class entity must compute its ID
600 # from genesis context — not randomly generated. Tests here are RED until Phase 4
601 # is implemented and will stay GREEN thereafter.
602 # ===========================================================================
603
604
605 class TestServiceLayerGenesisIds:
606 """Service creation functions assign genesis-addressed IDs, never random IDs."""
607
608 # ------------------------------------------------------------------
609 # Helpers shared across tests
610 # ------------------------------------------------------------------
611
612 def _async_session(self, *, execute_returns: MagicMock | None = None) -> "AsyncMock":
613 """Return a minimal AsyncMock DB session."""
614 from unittest.mock import AsyncMock, MagicMock
615
616 session = AsyncMock()
617 scalar = MagicMock()
618 scalar.scalar_one_or_none.return_value = execute_returns
619 scalar.scalar_one.return_value = None
620 session.execute.return_value = scalar
621 session.flush = AsyncMock()
622 session.commit = AsyncMock()
623 session.delete = AsyncMock()
624
625 async def _refresh(obj: MagicMock) -> None:
626 from datetime import datetime, timezone
627 for attr in ("created_at", "updated_at"):
628 if not getattr(obj, attr, None):
629 try:
630 setattr(obj, attr, datetime.now(timezone.utc))
631 except Exception:
632 pass
633 for attr in ("last_used_at",):
634 if not hasattr(obj, attr):
635 try:
636 setattr(obj, attr, None)
637 except Exception:
638 pass
639
640 session.refresh = _refresh
641 return session
642
643 def _keypair(self) -> None:
644 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
645 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
646 priv = Ed25519PrivateKey.generate()
647 pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
648 return priv, pub
649
650 # ------------------------------------------------------------------
651 # 6.1 Identity — register_agent_identity
652 # ------------------------------------------------------------------
653
654 @pytest.mark.asyncio
655 async def test_register_agent_identity_uses_compute_identity_id(self) -> None:
656 """register_agent_identity assigns identity_id = compute_identity_id(pub_bytes)."""
657 from muse.core.types import encode_pubkey, public_key_fingerprint
658 from musehub.services.musehub_auth import register_agent_identity
659
660 _, pub = self._keypair()
661 pub_b64 = encode_pubkey("ed25519", pub)
662 fp = public_key_fingerprint(pub)
663 expected = compute_identity_id(pub)
664
665 session = self._async_session()
666 await register_agent_identity(
667 session=session,
668 handle="test-agent",
669 public_key_b64=pub_b64,
670 fingerprint=fp,
671 algorithm="ed25519",
672 spawned_by="gabriel",
673 )
674
675 # First add() is the MusehubIdentity row.
676 identity = session.add.call_args_list[0][0][0]
677 assert identity.identity_id == expected, (
678 f"expected genesis ID {expected!r}, got {identity.identity_id!r}"
679 )
680 assert _CANONICAL_RE.match(identity.identity_id)
681
682 # ------------------------------------------------------------------
683 # 6.2 Session — upsert_session
684 # ------------------------------------------------------------------
685
686 @pytest.mark.asyncio
687 async def test_upsert_session_uses_compute_session_id(self) -> None:
688 """upsert_session assigns session_id = compute_session_id(repo_id, author_identity_id, started_at)."""
689 from unittest.mock import MagicMock, patch
690 from datetime import datetime, timezone
691 from musehub.models.musehub import SessionCreate
692 from musehub.services.musehub_sessions import upsert_session
693
694 repo_id = _REPO_ID
695 author_identity_id = _IDENTITY_ID
696 started_at = datetime(2026, 1, 6, 0, 0, 0, tzinfo=timezone.utc)
697 expected = compute_session_id(repo_id, author_identity_id, started_at.isoformat())
698
699 data = SessionCreate(started_at=started_at, participants=[], intent="", location="")
700 session = self._async_session()
701
702 with patch("musehub.services.musehub_sessions._to_response", return_value=MagicMock()):
703 await upsert_session(
704 session,
705 repo_id=repo_id,
706 author_identity_id=author_identity_id,
707 data=data,
708 )
709
710 added = session.add.call_args_list[0][0][0]
711 assert added.session_id == expected
712 assert _CANONICAL_RE.match(added.session_id)
713
714 # ------------------------------------------------------------------
715 # 6.3 Issue — create_issue
716 # ------------------------------------------------------------------
717
718 @pytest.mark.asyncio
719 async def test_create_issue_uses_compute_issue_id(self) -> None:
720 """create_issue assigns issue_id = compute_issue_id(repo_id, author_identity_id, created_at)."""
721 from unittest.mock import AsyncMock, MagicMock, patch
722 from datetime import datetime, timezone
723 from musehub.services import musehub_issues
724
725 repo_id = _REPO_ID
726 author_identity_id = _IDENTITY_ID
727 fixed_now = datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc)
728 expected = compute_issue_id(repo_id, author_identity_id, fixed_now.isoformat())
729
730 session = self._async_session()
731 # _next_issue_number calls session.execute(...).scalar_one_or_none()
732 # Return None so next number = 1.
733 session.execute.return_value.scalar_one_or_none.return_value = None
734
735 with patch("musehub.services.musehub_issues.datetime") as mock_dt:
736 mock_dt.now.return_value = fixed_now
737 mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
738
739 await musehub_issues.create_issue(
740 session,
741 repo_id=repo_id,
742 title="Test issue",
743 body="body",
744 labels=[],
745 author="gabriel",
746 author_identity_id=author_identity_id,
747 )
748
749 added = session.add.call_args_list[0][0][0]
750 assert added.issue_id == expected
751 assert _CANONICAL_RE.match(added.issue_id)
752
753 # ------------------------------------------------------------------
754 # 6.4 Proposal — create_proposal
755 # ------------------------------------------------------------------
756
757 @pytest.mark.asyncio
758 async def test_create_proposal_uses_compute_proposal_id(self) -> None:
759 """create_proposal assigns proposal_id = compute_proposal_id(...)."""
760 from unittest.mock import AsyncMock, MagicMock, patch
761 from datetime import datetime, timezone
762 from musehub.services import musehub_proposals
763
764 repo_id = _REPO_ID
765 author_identity_id = _IDENTITY_ID
766 from_branch = "feat/x"
767 to_branch = "main"
768 fixed_now = datetime(2026, 1, 3, 0, 0, 0, tzinfo=timezone.utc)
769 expected = compute_proposal_id(repo_id, author_identity_id, from_branch, to_branch, fixed_now.isoformat())
770
771 # _get_branch makes a DB call — return a fake branch row.
772 from unittest.mock import MagicMock
773 fake_branch = MagicMock()
774 fake_branch.head_commit_id = fake_id("branch-head-stub")
775
776 session = self._async_session()
777 # First execute: _get_branch → returns branch
778 # Second execute: max proposal_number → returns None
779 # Third execute: _touched_symbols → returns []
780 from unittest.mock import AsyncMock
781 results = [
782 MagicMock(**{"scalar_one_or_none.return_value": fake_branch}),
783 MagicMock(**{"scalar_one_or_none.return_value": None}),
784 MagicMock(**{"scalars.return_value.all.return_value": []}),
785 ]
786 session.execute.side_effect = results
787
788 with patch("musehub.services.musehub_proposals._utc_now", return_value=fixed_now):
789 await musehub_proposals.create_proposal(
790 session,
791 repo_id=repo_id,
792 title="Test proposal",
793 from_branch=from_branch,
794 to_branch=to_branch,
795 body="",
796 author="gabriel",
797 author_identity_id=author_identity_id,
798 )
799
800 added = session.add.call_args_list[0][0][0]
801 assert added.proposal_id == expected
802 assert _CANONICAL_RE.match(added.proposal_id)
803
804 # ------------------------------------------------------------------
805 # 6.5 Repo — create_repo
806 # ------------------------------------------------------------------
807
808 @pytest.mark.asyncio
809 async def test_create_repo_uses_compute_repo_id(self) -> None:
810 """create_repo assigns repo_id = compute_repo_id(owner_user_id, slug, domain, created_at)."""
811 from unittest.mock import patch
812 from datetime import datetime, timezone
813 from musehub.services import musehub_repository
814
815 owner_identity_id = _IDENTITY_ID
816 slug = "my-repo"
817 domain = "code"
818 fixed_now = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
819 expected = compute_repo_id(owner_identity_id, slug, domain, fixed_now.isoformat())
820
821 from unittest.mock import AsyncMock
822 session = self._async_session()
823 # template lookup returns None; no other DB reads needed.
824 session.get = AsyncMock(return_value=None)
825
826 from unittest.mock import MagicMock
827
828 with patch("musehub.services.musehub_repository.datetime") as mock_dt, \
829 patch("musehub.services.musehub_repository._to_repo_response", return_value=MagicMock()):
830 mock_dt.now.return_value = fixed_now
831 mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
832
833 await musehub_repository.create_repo(
834 session,
835 name="My Repo",
836 owner="gabriel",
837 visibility="public",
838 owner_user_id=compute_identity_id(b"gabriel"),
839 owner_identity_id=owner_identity_id,
840 domain=domain,
841 )
842
843 added = session.add.call_args_list[0][0][0]
844 assert added.repo_id == expected
845 assert _CANONICAL_RE.match(added.repo_id)
846
847
848 # ===========================================================================
849 # Phase 2 — Tier 1: canonical form for new genesis functions
850 # ===========================================================================
851
852
853 class TestPhase2CanonicalForm:
854
855 def test_label_id_canonical(self) -> None:
856 assert _CANONICAL_RE.match(_LABEL_ID)
857
858 def test_asset_id_canonical(self) -> None:
859 assert _CANONICAL_RE.match(_ASSET_ID)
860
861 def test_webhook_id_canonical(self) -> None:
862 assert _CANONICAL_RE.match(_WEBHOOK_ID)
863
864 def test_fork_id_canonical(self) -> None:
865 assert _CANONICAL_RE.match(_FORK_ID)
866
867 def test_collaborator_id_canonical(self) -> None:
868 assert _CANONICAL_RE.match(_COLLABORATOR_ID)
869
870 def test_key_id_canonical(self) -> None:
871 assert _CANONICAL_RE.match(_KEY_ID)
872
873 def test_domain_id_canonical(self) -> None:
874 assert _CANONICAL_RE.match(_DOMAIN_ID)
875
876
877 # ===========================================================================
878 # Phase 2 — Tier 2: determinism and collision resistance
879 # ===========================================================================
880
881
882 class TestPhase2Determinism:
883
884 def test_label_id_deterministic(self) -> None:
885 args = (_REPO_ID, "bug", "2026-01-09T00:00:00Z")
886 assert compute_label_id(*args) == compute_label_id(*args)
887
888 def test_label_different_names_differ(self) -> None:
889 a = compute_label_id(_REPO_ID, "bug", "2026-01-09T00:00:00Z")
890 b = compute_label_id(_REPO_ID, "enhancement", "2026-01-09T00:00:00Z")
891 assert a != b
892
893 def test_label_different_repos_differ(self) -> None:
894 repo2 = compute_repo_id(_IDENTITY_ID, "other-repo", "code", "2026-01-01T00:00:00Z")
895 a = compute_label_id(_REPO_ID, "bug", "2026-01-09T00:00:00Z")
896 b = compute_label_id(repo2, "bug", "2026-01-09T00:00:00Z")
897 assert a != b
898
899 def test_asset_id_deterministic(self) -> None:
900 args = (_RELEASE_ID, "v1.0.0-linux-amd64.tar.gz", "2026-01-10T00:00:00Z")
901 assert compute_asset_id(*args) == compute_asset_id(*args)
902
903 def test_asset_different_filenames_differ(self) -> None:
904 a = compute_asset_id(_RELEASE_ID, "linux.tar.gz", "2026-01-10T00:00:00Z")
905 b = compute_asset_id(_RELEASE_ID, "darwin.tar.gz", "2026-01-10T00:00:00Z")
906 assert a != b
907
908 def test_webhook_id_deterministic(self) -> None:
909 args = (_REPO_ID, "https://ci.example.com/hook", "2026-01-11T00:00:00Z")
910 assert compute_webhook_id(*args) == compute_webhook_id(*args)
911
912 def test_webhook_different_urls_differ(self) -> None:
913 a = compute_webhook_id(_REPO_ID, "https://ci.example.com/a", "2026-01-11T00:00:00Z")
914 b = compute_webhook_id(_REPO_ID, "https://ci.example.com/b", "2026-01-11T00:00:00Z")
915 assert a != b
916
917 def test_fork_id_deterministic(self) -> None:
918 args = (_REPO_ID, _FORK_REPO_ID, "2026-01-13T00:00:00Z")
919 assert compute_fork_id(*args) == compute_fork_id(*args)
920
921 def test_fork_different_source_repos_differ(self) -> None:
922 repo2 = compute_repo_id(_IDENTITY_ID, "other-repo", "code", "2026-01-01T00:00:00Z")
923 a = compute_fork_id(_REPO_ID, _FORK_REPO_ID, "2026-01-13T00:00:00Z")
924 b = compute_fork_id(repo2, _FORK_REPO_ID, "2026-01-13T00:00:00Z")
925 assert a != b
926
927 def test_collaborator_id_deterministic(self) -> None:
928 args = (_REPO_ID, _COLLAB_IDENTITY_ID, "2026-01-14T00:00:00Z")
929 assert compute_collaborator_id(*args) == compute_collaborator_id(*args)
930
931 def test_collaborator_different_identities_differ(self) -> None:
932 id2 = compute_identity_id(bytes(range(2, 34)))
933 a = compute_collaborator_id(_REPO_ID, _COLLAB_IDENTITY_ID, "2026-01-14T00:00:00Z")
934 b = compute_collaborator_id(_REPO_ID, id2, "2026-01-14T00:00:00Z")
935 assert a != b
936
937 def test_key_id_deterministic(self) -> None:
938 args = (_IDENTITY_ID, "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
939 assert compute_key_id(*args) == compute_key_id(*args)
940
941 def test_key_different_pubkeys_differ(self) -> None:
942 a = compute_key_id(_IDENTITY_ID, "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
943 b = compute_key_id(_IDENTITY_ID, "ed25519:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
944 assert a != b
945
946 def test_key_no_timestamp_by_design(self) -> None:
947 """compute_key_id takes no timestamp — a pubkey can only be registered once per identity."""
948 # Two calls with identical args must produce identical IDs (idempotent registration).
949 id1 = compute_key_id(_IDENTITY_ID, "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
950 id2 = compute_key_id(_IDENTITY_ID, "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
951 assert id1 == id2
952
953 def test_domain_id_deterministic(self) -> None:
954 args = ("gabriel", "code", "2026-01-15T00:00:00Z")
955 assert compute_domain_id(*args) == compute_domain_id(*args)
956
957 def test_domain_different_slugs_differ(self) -> None:
958 a = compute_domain_id("gabriel", "code", "2026-01-15T00:00:00Z")
959 b = compute_domain_id("gabriel", "music", "2026-01-15T00:00:00Z")
960 assert a != b
961
962 def test_all_phase2_ids_are_distinct(self) -> None:
963 ids = [_LABEL_ID, _ASSET_ID, _WEBHOOK_ID, _FORK_ID, _COLLABORATOR_ID, _KEY_ID, _DOMAIN_ID]
964 assert len(ids) == len(set(ids)), "two Phase 2 entity IDs collided"
965
966 def test_phase2_ids_distinct_from_phase1_ids(self) -> None:
967 phase1 = {_IDENTITY_ID, _REPO_ID, _ISSUE_ID, _PROPOSAL_ID, _RELEASE_ID, _TAG_ID, _SESSION_ID, _MIST_ID, _COMMENT_ID, _REVIEW_ID}
968 phase2 = {_LABEL_ID, _ASSET_ID, _WEBHOOK_ID, _FORK_ID, _COLLABORATOR_ID, _KEY_ID, _DOMAIN_ID}
969 assert phase1.isdisjoint(phase2), "a Phase 2 ID collided with a Phase 1 ID"
970
971
972 # ===========================================================================
973 # Phase 2 — Tier 3: separator injection safety
974 # ===========================================================================
975
976
977 class TestPhase2SeparatorInjection:
978
979 def test_label_nul_in_name_is_safe_canonical(self) -> None:
980 # NUL within a field value is structurally unusual; the function must
981 # still return a valid canonical ID even for exotic inputs.
982 a = compute_label_id(_REPO_ID, "bug\x00feature", "2026-01-09T00:00:00Z")
983 assert _CANONICAL_RE.match(a)
984
985 def test_webhook_url_with_colons_is_safe(self) -> None:
986 url = "https://user:[email protected]:8080/hook"
987 result = compute_webhook_id(_REPO_ID, url, "2026-01-11T00:00:00Z")
988 assert _CANONICAL_RE.match(result)
989
990 def test_domain_slug_with_path_separator_is_safe(self) -> None:
991 result = compute_domain_id("gabriel", "audio/midi", "2026-01-15T00:00:00Z")
992 assert _CANONICAL_RE.match(result)
993
994 def test_key_id_with_base64_padding_chars_is_safe(self) -> None:
995 pubkey = "ed25519:ABC+/DEF==padded=="
996 result = compute_key_id(_IDENTITY_ID, pubkey)
997 assert _CANONICAL_RE.match(result)
998
999 def test_fork_nul_in_repo_id_does_not_collide(self) -> None:
1000 # Synthesize two repo IDs that differ only in NUL placement
1001 a = compute_fork_id(_REPO_ID + "\x00x", _FORK_REPO_ID, "2026-01-13T00:00:00Z")
1002 b = compute_fork_id(_REPO_ID, "\x00x" + _FORK_REPO_ID, "2026-01-13T00:00:00Z")
1003 assert _CANONICAL_RE.match(a)
1004 assert a != b
1005
1006
1007 # ===========================================================================
1008 # Phase 2 — Tier 3b: Pydantic boundary enforcement for Phase 2 models
1009 # ===========================================================================
1010
1011
1012 class TestPydanticBoundaryPhase2:
1013
1014 # LabelResponse ──────────────────────────────────────────────────────────
1015
1016 def test_label_response_rejects_bad_label_id(self) -> None:
1017 with pytest.raises(ValidationError):
1018 LabelResponse(
1019 label_id="not-canonical",
1020 repo_id=_VALID_ID,
1021 name="bug",
1022 color="#d73a4a",
1023 description=None,
1024 created_at=_DT,
1025 )
1026
1027 def test_label_response_rejects_bad_repo_id(self) -> None:
1028 with pytest.raises(ValidationError):
1029 LabelResponse(
1030 label_id=_VALID_ID,
1031 repo_id="not-a-content-id",
1032 name="bug",
1033 color="#d73a4a",
1034 description=None,
1035 created_at=_DT,
1036 )
1037
1038 def test_label_response_accepts_future_algo(self) -> None:
1039 r = LabelResponse(
1040 label_id=_FUTURE_ID,
1041 repo_id=_FUTURE_ID,
1042 name="bug",
1043 color="#d73a4a",
1044 description=None,
1045 created_at=_DT,
1046 )
1047 assert r.label_id == _FUTURE_ID
1048
1049 # CollaboratorResponse ───────────────────────────────────────────────────
1050
1051 def test_collaborator_response_rejects_bad_collaborator_id(self) -> None:
1052 with pytest.raises(ValidationError):
1053 CollaboratorResponse(
1054 collaborator_id="bad-id",
1055 repo_id=_VALID_ID,
1056 handle="alice",
1057 permission="write",
1058 invited_by=None,
1059 )
1060
1061 def test_collaborator_response_rejects_bad_repo_id(self) -> None:
1062 with pytest.raises(ValidationError):
1063 CollaboratorResponse(
1064 collaborator_id=_VALID_ID,
1065 repo_id="not-genesis",
1066 handle="alice",
1067 permission="write",
1068 invited_by=None,
1069 )
1070
1071 def test_collaborator_response_accepts_future_algo(self) -> None:
1072 r = CollaboratorResponse(
1073 collaborator_id=_FUTURE_ID,
1074 repo_id=_FUTURE_ID,
1075 handle="alice",
1076 permission="write",
1077 invited_by=None,
1078 )
1079 assert r.collaborator_id == _FUTURE_ID
1080
1081 # WebhookResponse ────────────────────────────────────────────────────────
1082
1083 def test_webhook_response_rejects_bad_webhook_id(self) -> None:
1084 with pytest.raises(ValidationError):
1085 WebhookResponse(
1086 webhook_id="not-an-id",
1087 repo_id=_VALID_ID,
1088 url="https://ci.example.com/hook",
1089 events=["push"],
1090 active=True,
1091 created_at=_DT,
1092 updated_at=_DT,
1093 )
1094
1095 def test_webhook_response_rejects_bad_repo_id(self) -> None:
1096 with pytest.raises(ValidationError):
1097 WebhookResponse(
1098 webhook_id=_VALID_ID,
1099 repo_id="bad",
1100 url="https://ci.example.com/hook",
1101 events=["push"],
1102 active=True,
1103 created_at=_DT,
1104 updated_at=_DT,
1105 )
1106
1107 def test_webhook_response_accepts_future_algo(self) -> None:
1108 r = WebhookResponse(
1109 webhook_id=_FUTURE_ID,
1110 repo_id=_FUTURE_ID,
1111 url="https://ci.example.com/hook",
1112 events=["push"],
1113 active=True,
1114 created_at=_DT,
1115 updated_at=_DT,
1116 )
1117 assert r.webhook_id == _FUTURE_ID
1118
1119 # UserForkedRepoEntry ────────────────────────────────────────────────────
1120
1121 def _make_fork_repo_response(self, repo_id: str = _VALID_ID) -> None:
1122 from musehub.models.musehub import RepoResponse
1123 return RepoResponse(
1124 repo_id=repo_id,
1125 name="my-fork",
1126 owner="alice",
1127 slug="my-fork",
1128 visibility="public",
1129 owner_user_id=_VALID_ID,
1130 clone_url="https://musehub.ai/api/repos/x",
1131 created_at=_DT,
1132 updated_at=_DT,
1133 )
1134
1135 def test_forked_repo_entry_rejects_bad_fork_id(self) -> None:
1136 with pytest.raises(ValidationError):
1137 UserForkedRepoEntry(
1138 fork_id="not-a-content-id",
1139 fork_repo=self._make_fork_repo_response(),
1140 source_owner="gabriel",
1141 source_slug="original-repo",
1142 forked_at=_DT,
1143 )
1144
1145 def test_forked_repo_entry_rejects_bad_source_repo_id(self) -> None:
1146 # UserForkedRepoEntry doesn't hold source_repo_id directly — fork_id is the only genesis field
1147 # Validate that a bad fork_id fails and a good one passes even if source info is minimal
1148 with pytest.raises(ValidationError):
1149 UserForkedRepoEntry(
1150 fork_id="bad-id",
1151 fork_repo=self._make_fork_repo_response(),
1152 source_owner="gabriel",
1153 source_slug="original-repo",
1154 forked_at=_DT,
1155 )
1156
1157 def test_forked_repo_entry_rejects_bad_fork_repo_id(self) -> None:
1158 # The nested RepoResponse.repo_id is also genesis-validated
1159 with pytest.raises(ValidationError):
1160 UserForkedRepoEntry(
1161 fork_id=_VALID_ID,
1162 fork_repo=self._make_fork_repo_response(repo_id="bad-id"),
1163 source_owner="gabriel",
1164 source_slug="original-repo",
1165 forked_at=_DT,
1166 )
1167
1168 def test_forked_repo_entry_accepts_future_algo(self) -> None:
1169 from musehub.models.musehub import RepoResponse
1170 fake_repo = RepoResponse(
1171 repo_id=_FUTURE_ID,
1172 name="my-fork",
1173 owner="alice",
1174 slug="my-fork",
1175 visibility="public",
1176 owner_user_id=_FUTURE_ID,
1177 clone_url="https://musehub.ai/api/repos/x",
1178 created_at=_DT,
1179 updated_at=_DT,
1180 )
1181 r = UserForkedRepoEntry(
1182 fork_id=_FUTURE_ID,
1183 fork_repo=fake_repo,
1184 source_owner="gabriel",
1185 source_slug="original-repo",
1186 forked_at=_DT,
1187 )
1188 assert r.fork_id == _FUTURE_ID
1189
1190
1191 # ===========================================================================
1192 # Phase 2 — Tier 6: service-layer genesis contract tests
1193 # ===========================================================================
1194
1195
1196 class TestServiceLayerPhase2GenesisIds:
1197 """Phase 2 service creation functions assign genesis-addressed IDs, never random IDs."""
1198
1199 def _async_session(self, *, execute_returns: MagicMock | None = None) -> "AsyncMock":
1200 from unittest.mock import AsyncMock, MagicMock
1201
1202 session = AsyncMock()
1203 scalar = MagicMock()
1204 scalar.scalar_one_or_none.return_value = execute_returns
1205 session.execute.return_value = scalar
1206 session.commit = AsyncMock()
1207 session.flush = AsyncMock()
1208
1209 async def _refresh(obj: MagicMock) -> None:
1210 from datetime import datetime, timezone
1211 for attr in ("created_at", "updated_at"):
1212 if not getattr(obj, attr, None):
1213 try:
1214 setattr(obj, attr, datetime.now(timezone.utc))
1215 except Exception:
1216 pass
1217
1218 session.refresh = _refresh
1219 return session
1220
1221 # 6.6 Label — create_label ─────────────────────────────────────────────
1222
1223 @pytest.mark.asyncio
1224 async def test_create_label_uses_compute_label_id(self) -> None:
1225 """create_label calls compute_label_id(repo_id, name, iso_ts) — not a random ID."""
1226 from unittest.mock import patch, AsyncMock, MagicMock
1227 import musehub.api.routes.musehub.labels as labels_module
1228
1229 repo_id = _REPO_ID
1230 name = "bug"
1231
1232 captured: list[tuple] = []
1233 _real = compute_label_id
1234 def _spy(r: str, n: str, t: str) -> None:
1235 result = _real(r, n, t)
1236 captured.append((r, n, t, result))
1237 return result
1238
1239 db = self._async_session()
1240 # _guard_repo_owner → get_repo + check_write_access; uniqueness check → no duplicate
1241 db.execute.return_value.scalar_one_or_none.return_value = None
1242
1243 with patch("musehub.api.routes.musehub.labels.musehub_repository") as mock_svc, \
1244 patch("musehub.api.routes.musehub.labels.compute_label_id", side_effect=_spy):
1245 fake_repo = MagicMock()
1246 mock_svc.get_repo = AsyncMock(return_value=fake_repo)
1247 mock_svc.check_write_access = AsyncMock(return_value=True)
1248
1249 try:
1250 await labels_module.create_label(
1251 repo_id=repo_id,
1252 body=labels_module.LabelCreate(name=name, color="#d73a4a"),
1253 db=db,
1254 token=MagicMock(handle="gabriel"),
1255 )
1256 except Exception:
1257 pass
1258
1259 assert captured, "compute_label_id was never called — label creation is broken"
1260 call_repo_id, call_name, call_ts, call_result = captured[0]
1261 assert call_repo_id == repo_id
1262 assert call_name == name
1263 assert _CANONICAL_RE.match(call_result)
1264
1265 # 6.7 Webhook — create_webhook ─────────────────────────────────────────
1266
1267 @pytest.mark.asyncio
1268 async def test_create_webhook_uses_compute_webhook_id(self) -> None:
1269 """Webhook rows are assigned compute_webhook_id(repo_id, url, created_at_iso)."""
1270 from unittest.mock import patch, AsyncMock, MagicMock
1271 from musehub.services.musehub_webhook_dispatcher import create_webhook
1272
1273 repo_id = _REPO_ID
1274 url = "https://ci.example.com/hook"
1275
1276 captured: list[tuple] = []
1277 _real = compute_webhook_id
1278 def _spy(r: str, u: str, t: str) -> None:
1279 result = _real(r, u, t)
1280 captured.append((r, u, t, result))
1281 return result
1282
1283 db = self._async_session()
1284
1285 with patch("musehub.services.musehub_webhook_dispatcher.compute_webhook_id", side_effect=_spy):
1286 try:
1287 await create_webhook(db, repo_id=repo_id, url=url, events=["push"], secret="")
1288 except Exception:
1289 pass
1290
1291 assert captured, "compute_webhook_id was never called — webhook creation is broken"
1292 call_repo_id, call_url, call_ts, call_result = captured[0]
1293 assert call_repo_id == repo_id
1294 assert call_url == url
1295 assert _CANONICAL_RE.match(call_result)
1296 # Verify the row passed to session.add has that ID
1297 if db.add.called:
1298 row = db.add.call_args_list[0][0][0]
1299 assert row.webhook_id == call_result
1300
1301 # 6.8 Fork — compute_fork_id called at fork creation ───────────────────
1302
1303 @pytest.mark.asyncio
1304 async def test_fork_repo_uses_compute_fork_id(self) -> None:
1305 """fork_repo calls compute_fork_id(source_repo_id, fork_repo_id, created_at_iso)."""
1306 from unittest.mock import patch, AsyncMock, MagicMock
1307 from musehub.services.musehub_repository import fork_repo
1308 from musehub.models.musehub import ForkRepoRequest
1309 from datetime import datetime, timezone
1310
1311 source_repo_id = _REPO_ID
1312 captured: list[tuple] = []
1313 _real = compute_fork_id
1314 def _spy(src: str, frk: str, t: str) -> None:
1315 result = _real(src, frk, t)
1316 captured.append((src, frk, t, result))
1317 return result
1318
1319 fake_source_repo = MagicMock()
1320 fake_source_repo.repo_id = source_repo_id
1321 fake_source_repo.name = "my-repo"
1322 fake_source_repo.slug = "my-repo"
1323 fake_source_repo.visibility = "public"
1324 fake_source_repo.description = "A test repo"
1325 fake_source_repo.domain_id = None
1326 fake_source_repo.owner = "gabriel"
1327 fake_source_repo.tags = []
1328
1329 db = self._async_session()
1330 call_count = 0
1331 def _execute(*a: MagicMock, **kw: MagicMock) -> None:
1332 nonlocal call_count
1333 call_count += 1
1334 m = MagicMock()
1335 if call_count == 1:
1336 m.scalar_one_or_none.return_value = fake_source_repo # source repo
1337 else:
1338 m.scalar_one_or_none.return_value = None # no duplicate / no slug collision
1339 return m
1340 db.execute.side_effect = _execute
1341
1342 # After flush()+refresh(), the fork_repo_row needs a repo_id so compute_fork_id can use it.
1343 # The DB would normally auto-assign it; we supply a canonical stub.
1344 _fork_repo_id_stub = fake_id("fork-repo-stub")
1345 refresh_count = 0
1346 async def _refresh_with_repo_id(obj: MagicMock) -> None:
1347 nonlocal refresh_count
1348 refresh_count += 1
1349 now = datetime.now(timezone.utc)
1350 for attr in ("created_at", "updated_at"):
1351 if not getattr(obj, attr, None):
1352 try:
1353 setattr(obj, attr, now)
1354 except Exception:
1355 pass
1356 # First refresh is for the fork repo row — give it a genesis-style repo_id.
1357 if refresh_count == 1 and hasattr(obj, "repo_id") and not getattr(obj, "repo_id", None):
1358 try:
1359 obj.repo_id = _fork_repo_id_stub
1360 except Exception:
1361 pass
1362 db.refresh = _refresh_with_repo_id
1363
1364 with patch("musehub.services.musehub_repository.compute_fork_id", side_effect=_spy):
1365 try:
1366 await fork_repo(
1367 db,
1368 source_repo_id=source_repo_id,
1369 forked_by_handle="alice",
1370 request=ForkRepoRequest(name=None),
1371 )
1372 except Exception:
1373 pass
1374
1375 assert captured, "compute_fork_id was never called — fork creation is broken"
1376 call_src, call_frk, call_ts, call_result = captured[0]
1377 assert call_src == source_repo_id
1378 assert _CANONICAL_RE.match(call_result)
1379
1380 # 6.9 Auth key — register_identity key_id ──────────────────────────────
1381
1382 @pytest.mark.asyncio
1383 async def test_register_key_uses_compute_key_id(self) -> None:
1384 """MusehubAuthKey rows use compute_key_id(identity_id, public_key_b64) — no random IDs."""
1385 from muse.core.types import encode_pubkey, public_key_fingerprint
1386 from musehub.services.musehub_auth import register_agent_identity
1387
1388 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
1389 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
1390 priv = Ed25519PrivateKey.generate()
1391 pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
1392 pub_b64 = encode_pubkey("ed25519", pub)
1393 fp = public_key_fingerprint(pub)
1394 expected_identity_id = compute_identity_id(pub)
1395 expected_key_id = compute_key_id(expected_identity_id, pub_b64)
1396
1397 db = self._async_session()
1398 await register_agent_identity(
1399 session=db,
1400 handle="key-test-agent",
1401 public_key_b64=pub_b64,
1402 fingerprint=fp,
1403 algorithm="ed25519",
1404 spawned_by="gabriel",
1405 )
1406
1407 # add() call list: [0] = identity, [1] = key
1408 assert len(db.add.call_args_list) >= 2
1409 key_row = db.add.call_args_list[1][0][0]
1410 assert key_row.key_id == expected_key_id
1411 assert _CANONICAL_RE.match(key_row.key_id)
1412
1413 # 6.10 Collaborator invite — invite_collaborator ────────────────────────
1414
1415 @pytest.mark.asyncio
1416 async def test_invite_collaborator_uses_compute_collaborator_id(self) -> None:
1417 """Collaborator rows use compute_collaborator_id(repo_id, identity_id, invited_at_iso)."""
1418 from unittest.mock import patch, AsyncMock, MagicMock
1419 from datetime import datetime, timezone
1420 import musehub.api.routes.musehub.collaborators as collabs_module
1421
1422 repo_id = _REPO_ID
1423 fixed_now = datetime(2026, 1, 14, 0, 0, 0, tzinfo=timezone.utc)
1424 invitee_identity_id = _COLLAB_IDENTITY_ID
1425 expected = compute_collaborator_id(repo_id, invitee_identity_id, fixed_now.isoformat())
1426
1427 # Repo owner must match the actor so the 403 guard passes.
1428 fake_repo = MagicMock()
1429 fake_repo.owner = "gabriel"
1430
1431 fake_invitee_identity = MagicMock()
1432 fake_invitee_identity.identity_id = invitee_identity_id
1433
1434 db = self._async_session()
1435 call_count = 0
1436 def _execute(*a: MagicMock, **kw: MagicMock) -> None:
1437 nonlocal call_count
1438 call_count += 1
1439 m = MagicMock()
1440 if call_count == 1:
1441 m.scalar_one_or_none.return_value = MagicMock(permission="owner") # actor perm
1442 elif call_count == 2:
1443 m.scalar_one_or_none.return_value = None # no duplicate
1444 else:
1445 m.scalar_one_or_none.return_value = fake_invitee_identity # invitee lookup
1446 return m
1447 db.execute.side_effect = _execute
1448
1449 with patch("musehub.api.routes.musehub.collaborators.musehub_repository") as mock_svc, \
1450 patch("musehub.api.routes.musehub.collaborators.datetime") as mock_dt:
1451 mock_svc.get_repo = AsyncMock(return_value=fake_repo)
1452 mock_dt.now.return_value = fixed_now
1453 mock_dt.timezone = timezone
1454
1455 try:
1456 await collabs_module.invite_collaborator(
1457 repo_id=repo_id,
1458 body=collabs_module.CollaboratorInviteRequest(handle="alice"),
1459 db=db,
1460 token=MagicMock(handle="gabriel"),
1461 )
1462 except Exception:
1463 pass
1464
1465 assert db.add.called, "db.add was never called — collaborator row was not created"
1466 row = db.add.call_args_list[0][0][0]
1467 assert row.id == expected, f"expected {expected!r}, got {row.id!r}"
1468 assert _CANONICAL_RE.match(row.id)
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago