gabriel / musehub public
test_identity_push_validator_wire_integration.py python
227 lines 8.1 KB
Raw
sha256:066f0f58448cc0553bff3c845cbb8e1f7a6bdecf53fce6bc5c2ff5e0ddad3d71 feat: wire IdentityPushValidator into the real push pipelin… Sonnet 5 minor ⚠ breaking 2 days ago
1 """TDD — musehub#230: IdentityPushValidator was never invoked by the real
2 wire_push_unpack_mpack pipeline -- I1/I2/I3 were enforced by nothing on an
3 actual push, despite push_validator.py's own docstring claiming otherwise
4 (the false claim #227's audit table also repeated).
5
6 This test exercises the real, live wire-push endpoint function end-to-end --
7 not IdentityPushValidator directly (tests/test_identity_push_validator.py
8 already covers that in isolation) -- with an identity-domain commit whose
9 committed state contains a real I1 violation (a 2-node cycle: alice spawns
10 bob, bob spawns alice). It must be rejected by the actual push pipeline,
11 not merely by calling the validator class in a unit test.
12
13 RED before the fix: the push succeeds; the cyclic relationship is persisted.
14 GREEN after: wire_push_unpack_mpack detects the identity domain, reconstructs
15 the full identities/relationships snapshot, runs IdentityPushValidator, and
16 rejects the push with ValueError (-> HTTP 422 at the route layer) before any
17 objects are committed.
18 """
19 from __future__ import annotations
20
21 import datetime
22 from unittest.mock import AsyncMock, MagicMock, patch
23
24 import pytest
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from muse.core.ids import hash_snapshot
28 from muse.core.mpack import build_wire_mpack
29 from muse.core.types import blob_id
30 from muse.plugins.identity.records import (
31 IdentityRecord,
32 RelationshipRecord,
33 identity_path,
34 record_to_bytes,
35 relationship_path,
36 )
37 from musehub.core.genesis import compute_identity_id
38 from musehub.db.musehub_repo_models import MusehubCommit
39 from musehub.services.musehub_repository import create_repo
40 from musehub.services.musehub_wire_push import wire_push_unpack_mpack
41
42 _OWNER = "gabriel"
43 _IDENTITY_ID = compute_identity_id(b"gabriel")
44
45
46 def _cid(seed: str) -> str:
47 return blob_id(f"identity-push-validator-wire-{seed}".encode())
48
49
50 def _now() -> datetime.datetime:
51 return datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
52
53
54 def _raw_commit(cid: str, snap_id: str) -> dict:
55 return {
56 "commit_id": cid,
57 "branch": "main",
58 "message": f"commit {cid[:12]}",
59 "author": _OWNER,
60 "committed_at": _now().isoformat(),
61 "parent_commit_id": None,
62 "parent2_commit_id": None,
63 "snapshot_id": snap_id,
64 "agent_id": "",
65 "model_id": "",
66 "toolchain_id": "",
67 "sem_ver_bump": "none",
68 "breaking_changes": [],
69 "signature": "",
70 "signer_key_id": "",
71 "signer_public_key": "",
72 "prompt_hash": "",
73 }
74
75
76 def _mock_backend(mpack_bytes: bytes) -> MagicMock:
77 backend = MagicMock()
78 backend.get_mpack = AsyncMock(return_value=mpack_bytes)
79 backend.put = AsyncMock(return_value=None)
80 backend.put_mpack = AsyncMock(return_value=None)
81 backend.quarantine_mpack = AsyncMock(return_value=None)
82 backend.presign_get = AsyncMock(return_value="")
83 backend.presign_mpack_get = AsyncMock(return_value="")
84 return backend
85
86
87 def _identity_record(handle: str, kind: str) -> IdentityRecord:
88 return {
89 "handle": handle,
90 "type": kind, # type: ignore[typeddict-item]
91 "pubkey": None,
92 "quorum": None,
93 "registered_at": _now().isoformat(),
94 "metadata": {},
95 }
96
97
98 def _spawns(frm: str, to: str, signer: str) -> RelationshipRecord:
99 return {
100 "from_handle": frm,
101 "to_handle": to,
102 "edge_type": "spawns",
103 "weight": None,
104 "authorized_by": [{"signer": signer, "signature": "sig", "signed_at": _now().isoformat()}],
105 }
106
107
108 async def _push_identity_commit(
109 db_session: AsyncSession,
110 *,
111 repo_name: str,
112 manifest: dict[str, bytes],
113 ) -> tuple[str, bytes]:
114 """Create an identity-domain repo and build the mpack for one commit
115 whose snapshot contains ``manifest`` (path -> file content). Returns
116 (repo_id, mpack_bytes); does not push -- caller drives wire_push_unpack_mpack
117 so it can assert on the raised exception or the resulting state."""
118 repo = await create_repo(
119 db_session,
120 name=repo_name,
121 owner=_OWNER,
122 owner_user_id=_IDENTITY_ID,
123 visibility="private",
124 domain="identity",
125 initialize=False,
126 )
127 await db_session.commit()
128
129 oid_manifest = {path: blob_id(content) for path, content in manifest.items()}
130 snap_id = hash_snapshot(oid_manifest, [])
131 commit_id = _cid(repo_name)
132
133 mpack_bytes = build_wire_mpack({
134 "commits": [_raw_commit(commit_id, snap_id)],
135 "snapshots": [{
136 "snapshot_id": snap_id,
137 "parent_snapshot_id": None,
138 "delta_upsert": oid_manifest,
139 "delta_remove": [],
140 "directories": [],
141 }],
142 "blobs": [
143 {"object_id": oid, "content": manifest[path]}
144 for path, oid in oid_manifest.items()
145 ],
146 "tags": [],
147 })
148 return repo.repo_id, mpack_bytes, commit_id
149
150
151 @pytest.mark.asyncio
152 async def test_identity_cycle_is_rejected_by_the_real_wire_push_endpoint(
153 db_session: AsyncSession,
154 ) -> None:
155 """RED: a push introducing a 2-node identity cycle (alice spawns bob,
156 bob spawns alice -- an I1 violation) must be rejected by the live
157 wire_push_unpack_mpack pipeline, not silently accepted."""
158 manifest = {
159 identity_path("alice"): record_to_bytes(_identity_record("alice", "human")),
160 identity_path("bob"): record_to_bytes(_identity_record("bob", "agent")),
161 relationship_path("alice", "spawns", "bob"): record_to_bytes(_spawns("alice", "bob", "alice")),
162 relationship_path("bob", "spawns", "alice"): record_to_bytes(_spawns("bob", "alice", "bob")),
163 }
164 repo_id, mpack_bytes, commit_id = await _push_identity_commit(
165 db_session, repo_name="identity-cycle-repro", manifest=manifest
166 )
167 mpack_key = blob_id(mpack_bytes)
168 backend = _mock_backend(mpack_bytes)
169
170 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
171 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
172 patch("musehub.storage.backends.get_backend", return_value=backend):
173 with pytest.raises(ValueError, match="I1"):
174 await wire_push_unpack_mpack(
175 db_session,
176 repo_id,
177 mpack_key,
178 pusher_id=_OWNER,
179 branch="main",
180 head_commit_id=commit_id,
181 commits_count=1,
182 blobs_count=4,
183 force=True,
184 )
185
186 stored = await db_session.get(MusehubCommit, commit_id)
187 assert stored is None, (
188 "a push containing an identity-graph cycle must be rejected outright -- "
189 "the commit must never be persisted"
190 )
191
192
193 @pytest.mark.asyncio
194 async def test_valid_identity_push_is_accepted_by_the_real_wire_push_endpoint(
195 db_session: AsyncSession,
196 ) -> None:
197 """GREEN control: a valid, acyclic, fully-authorized identity push must
198 still succeed -- the new validation must not reject legitimate pushes."""
199 manifest = {
200 identity_path("carol"): record_to_bytes(_identity_record("carol", "human")),
201 identity_path("dave"): record_to_bytes(_identity_record("dave", "agent")),
202 relationship_path("carol", "spawns", "dave"): record_to_bytes(_spawns("carol", "dave", "carol")),
203 }
204 repo_id, mpack_bytes, commit_id = await _push_identity_commit(
205 db_session, repo_name="identity-valid-push", manifest=manifest
206 )
207 mpack_key = blob_id(mpack_bytes)
208 backend = _mock_backend(mpack_bytes)
209
210 with patch("musehub.services.musehub_wire.get_backend", return_value=backend), \
211 patch("musehub.services.musehub_wire_push.get_backend", return_value=backend), \
212 patch("musehub.storage.backends.get_backend", return_value=backend):
213 await wire_push_unpack_mpack(
214 db_session,
215 repo_id,
216 mpack_key,
217 pusher_id=_OWNER,
218 branch="main",
219 head_commit_id=commit_id,
220 commits_count=1,
221 blobs_count=3,
222 force=True,
223 )
224 await db_session.flush()
225
226 stored = await db_session.get(MusehubCommit, commit_id)
227 assert stored is not None, "a valid identity push must be accepted and persisted"
File History 1 commit
sha256:066f0f58448cc0553bff3c845cbb8e1f7a6bdecf53fce6bc5c2ff5e0ddad3d71 feat: wire IdentityPushValidator into the real push pipelin… Sonnet 5 minor ⚠ 2 days ago