test_identity_repo_phase3.py
python
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago
| 1 | """Phase 3 — Agent SPAWNS relationship committed to parent's identity repo. |
| 2 | |
| 3 | TDD regression suite: every test starts RED and turns GREEN as the feature |
| 4 | is implemented. Their permanent role is to prevent regressions. |
| 5 | |
| 6 | What this phase covers: |
| 7 | - register_agent_identity() commits a RelationshipRecord to the parent's |
| 8 | identity repo (not the agent's) |
| 9 | - The relationship file path is relationships/{parent}--spawns--{agent}.json |
| 10 | - The RelationshipRecord has correct from_handle, to_handle, edge_type |
| 11 | - The parent's identity repo gains exactly one new commit per agent spawned |
| 12 | - The agent's own identity repo is NOT modified (SPAWNS lives on the parent) |
| 13 | - No-op when the parent has no identity repo (migration-period safety) |
| 14 | """ |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import json |
| 18 | |
| 19 | import pytest |
| 20 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 21 | from muse.core.types import encode_pubkey, encode_sig |
| 22 | from muse.plugins.identity.records import relationship_path |
| 23 | from sqlalchemy import select |
| 24 | from sqlalchemy.ext.asyncio import AsyncSession |
| 25 | |
| 26 | from musehub.crypto.keys import key_fingerprint |
| 27 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot |
| 28 | from musehub.services.musehub_auth import ( |
| 29 | create_challenge, |
| 30 | register_agent_identity, |
| 31 | verify_and_authenticate, |
| 32 | ) |
| 33 | from musehub.types.json_types import JSONObject |
| 34 | |
| 35 | |
| 36 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 37 | |
| 38 | |
| 39 | def _keypair() -> tuple[Ed25519PrivateKey, bytes]: |
| 40 | priv = Ed25519PrivateKey.generate() |
| 41 | pub = priv.public_key().public_bytes_raw() |
| 42 | return priv, pub |
| 43 | |
| 44 | |
| 45 | def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str: |
| 46 | sig_bytes = priv.sign(bytes.fromhex(nonce_hex)) |
| 47 | return encode_sig("ed25519", sig_bytes) |
| 48 | |
| 49 | |
| 50 | async def _register_human( |
| 51 | session: AsyncSession, handle: str |
| 52 | ) -> tuple[str, str]: |
| 53 | """Register a human identity; returns (identity_id, public_key_b64).""" |
| 54 | priv, pub = _keypair() |
| 55 | pub_b64 = encode_pubkey("ed25519", pub) |
| 56 | fp = key_fingerprint(pub) |
| 57 | nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") |
| 58 | sig = _sign_nonce(priv, nonce) |
| 59 | result = await verify_and_authenticate( |
| 60 | session=session, |
| 61 | challenge_token=nonce, |
| 62 | public_key_b64=pub_b64, |
| 63 | signature_b64=sig, |
| 64 | handle=handle, |
| 65 | display_name=handle, |
| 66 | label="key", |
| 67 | ) |
| 68 | return result.identity_id, pub_b64 |
| 69 | |
| 70 | |
| 71 | async def _spawn_agent( |
| 72 | session: AsyncSession, agent_handle: str, spawned_by: str |
| 73 | ) -> None: |
| 74 | """Register a fresh agent identity under a given parent.""" |
| 75 | _, pub = _keypair() |
| 76 | pub_b64 = encode_pubkey("ed25519", pub) |
| 77 | fp = key_fingerprint(pub) |
| 78 | await register_agent_identity( |
| 79 | session=session, |
| 80 | handle=agent_handle, |
| 81 | public_key_b64=pub_b64, |
| 82 | fingerprint=fp, |
| 83 | algorithm="ed25519", |
| 84 | spawned_by=spawned_by, |
| 85 | ) |
| 86 | |
| 87 | |
| 88 | async def _get_identity_repo_commits( |
| 89 | session: AsyncSession, owner: str |
| 90 | ) -> list[MusehubCommit]: |
| 91 | repo_result = await session.execute( |
| 92 | select(MusehubRepo).where( |
| 93 | MusehubRepo.owner == owner, |
| 94 | MusehubRepo.slug == "identity", |
| 95 | ) |
| 96 | ) |
| 97 | repo = repo_result.scalar_one() |
| 98 | commits_result = await session.execute( |
| 99 | select(MusehubCommit) |
| 100 | .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id) |
| 101 | .where( |
| 102 | MusehubCommitRef.repo_id == repo.repo_id, |
| 103 | MusehubCommit.branch == "main", |
| 104 | ).order_by(MusehubCommit.timestamp) |
| 105 | ) |
| 106 | return list(commits_result.scalars().all()) |
| 107 | |
| 108 | |
| 109 | async def _read_manifest_from_commit( |
| 110 | session: AsyncSession, commit: MusehubCommit |
| 111 | ) -> JSONObject: |
| 112 | import msgpack |
| 113 | snap = await session.get(MusehubSnapshot, commit.snapshot_id) |
| 114 | assert snap is not None |
| 115 | return msgpack.unpackb(snap.manifest_blob, raw=False) |
| 116 | |
| 117 | |
| 118 | async def _read_object_bytes(session: AsyncSession, object_id: str) -> bytes: |
| 119 | from musehub.storage.backends import read_object_bytes |
| 120 | obj = await session.get(MusehubObject, object_id) |
| 121 | assert obj is not None |
| 122 | raw = await read_object_bytes(obj) |
| 123 | assert raw is not None |
| 124 | return raw |
| 125 | |
| 126 | |
| 127 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 128 | # 1. SPAWNS relationship committed to parent's identity repo |
| 129 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 130 | |
| 131 | |
| 132 | class TestSpawnsRelationshipCreated: |
| 133 | async def test_spawning_adds_commit_to_parent_identity_repo( |
| 134 | self, db_session: AsyncSession |
| 135 | ) -> None: |
| 136 | """register_agent_identity must add a new commit to the parent's identity repo.""" |
| 137 | await _register_human(db_session, "parent3a") |
| 138 | await _spawn_agent(db_session, "agent3a", spawned_by="parent3a") |
| 139 | |
| 140 | commits = await _get_identity_repo_commits(db_session, "parent3a") |
| 141 | assert len(commits) == 2, ( |
| 142 | f"Expected 2 commits on parent's identity repo after spawning an agent, " |
| 143 | f"got {len(commits)}." |
| 144 | ) |
| 145 | |
| 146 | async def test_spawns_commit_contains_relationship_file( |
| 147 | self, db_session: AsyncSession |
| 148 | ) -> None: |
| 149 | """The SPAWNS commit must include the relationship file at the correct path.""" |
| 150 | await _register_human(db_session, "parent3b") |
| 151 | await _spawn_agent(db_session, "agent3b", spawned_by="parent3b") |
| 152 | |
| 153 | commits = await _get_identity_repo_commits(db_session, "parent3b") |
| 154 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 155 | |
| 156 | expected_path = relationship_path("parent3b", "spawns", "agent3b") |
| 157 | assert expected_path in manifest, ( |
| 158 | f"Expected {expected_path!r} in parent's identity repo manifest, " |
| 159 | f"got keys: {list(manifest)!r}." |
| 160 | ) |
| 161 | |
| 162 | async def test_spawning_two_agents_adds_two_commits_to_parent( |
| 163 | self, db_session: AsyncSession |
| 164 | ) -> None: |
| 165 | """Each agent spawned produces its own commit on the parent's identity repo.""" |
| 166 | await _register_human(db_session, "parent3c") |
| 167 | await _spawn_agent(db_session, "agent3c1", spawned_by="parent3c") |
| 168 | await _spawn_agent(db_session, "agent3c2", spawned_by="parent3c") |
| 169 | |
| 170 | commits = await _get_identity_repo_commits(db_session, "parent3c") |
| 171 | assert len(commits) == 3, ( |
| 172 | f"Expected 3 commits (initial + 2 spawns), got {len(commits)}." |
| 173 | ) |
| 174 | |
| 175 | async def test_agent_own_identity_repo_unchanged( |
| 176 | self, db_session: AsyncSession |
| 177 | ) -> None: |
| 178 | """The SPAWNS relationship lives on the parent — the agent's own repo must |
| 179 | have exactly 1 commit (its initial registration only).""" |
| 180 | await _register_human(db_session, "parent3d") |
| 181 | await _spawn_agent(db_session, "agent3d", spawned_by="parent3d") |
| 182 | |
| 183 | agent_commits = await _get_identity_repo_commits(db_session, "agent3d") |
| 184 | assert len(agent_commits) == 1, ( |
| 185 | f"Agent's own identity repo should have only 1 commit (initial registration), " |
| 186 | f"got {len(agent_commits)}." |
| 187 | ) |
| 188 | |
| 189 | |
| 190 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 191 | # 2. RelationshipRecord content is correct |
| 192 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 193 | |
| 194 | |
| 195 | class TestRelationshipRecordContent: |
| 196 | async def test_relationship_record_from_handle( |
| 197 | self, db_session: AsyncSession |
| 198 | ) -> None: |
| 199 | await _register_human(db_session, "parent3e") |
| 200 | await _spawn_agent(db_session, "agent3e", spawned_by="parent3e") |
| 201 | |
| 202 | commits = await _get_identity_repo_commits(db_session, "parent3e") |
| 203 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 204 | rel_path = relationship_path("parent3e", "spawns", "agent3e") |
| 205 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 206 | record = json.loads(raw) |
| 207 | |
| 208 | assert record["from_handle"] == "parent3e" |
| 209 | |
| 210 | async def test_relationship_record_to_handle( |
| 211 | self, db_session: AsyncSession |
| 212 | ) -> None: |
| 213 | await _register_human(db_session, "parent3f") |
| 214 | await _spawn_agent(db_session, "agent3f", spawned_by="parent3f") |
| 215 | |
| 216 | commits = await _get_identity_repo_commits(db_session, "parent3f") |
| 217 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 218 | rel_path = relationship_path("parent3f", "spawns", "agent3f") |
| 219 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 220 | record = json.loads(raw) |
| 221 | |
| 222 | assert record["to_handle"] == "agent3f" |
| 223 | |
| 224 | async def test_relationship_record_edge_type_is_spawns( |
| 225 | self, db_session: AsyncSession |
| 226 | ) -> None: |
| 227 | await _register_human(db_session, "parent3g") |
| 228 | await _spawn_agent(db_session, "agent3g", spawned_by="parent3g") |
| 229 | |
| 230 | commits = await _get_identity_repo_commits(db_session, "parent3g") |
| 231 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 232 | rel_path = relationship_path("parent3g", "spawns", "agent3g") |
| 233 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 234 | record = json.loads(raw) |
| 235 | |
| 236 | assert record["edge_type"] == "spawns", ( |
| 237 | f"Expected edge_type='spawns', got {record['edge_type']!r}." |
| 238 | ) |
| 239 | |
| 240 | |
| 241 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 242 | # 3. No-op when parent has no identity repo |
| 243 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 244 | |
| 245 | |
| 246 | class TestSpawnsNoOpWhenParentMissing: |
| 247 | async def test_spawning_without_parent_identity_repo_does_not_raise( |
| 248 | self, db_session: AsyncSession |
| 249 | ) -> None: |
| 250 | """If the parent has no identity repo (pre-migration user), spawning must |
| 251 | not raise — the SPAWNS commit is simply skipped.""" |
| 252 | # Spawn an agent whose parent ("ghost-parent") has never registered |
| 253 | # and therefore has no identity repo. |
| 254 | _, pub = _keypair() |
| 255 | pub_b64 = encode_pubkey("ed25519", pub) |
| 256 | fp = key_fingerprint(pub) |
| 257 | |
| 258 | # Must not raise. |
| 259 | await register_agent_identity( |
| 260 | session=db_session, |
| 261 | handle="agent3h", |
| 262 | public_key_b64=pub_b64, |
| 263 | fingerprint=fp, |
| 264 | algorithm="ed25519", |
| 265 | spawned_by="ghost-parent", |
| 266 | ) |
| 267 | |
| 268 | # Agent's own repo still created correctly. |
| 269 | result = await db_session.execute( |
| 270 | select(MusehubRepo).where( |
| 271 | MusehubRepo.owner == "agent3h", |
| 272 | MusehubRepo.slug == "identity", |
| 273 | ) |
| 274 | ) |
| 275 | assert result.scalar_one_or_none() is not None |
File History
1 commit
sha256:35d76015db2541686c33edd44343ea2d9f751325b4a5556cc9c4c9c0f84edbbe
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
6 days ago