test_identity_push_validator_wire_integration.py
python
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