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