gabriel / musehub public
test_identity_repo_phase3.py python
275 lines 11.4 KB
Raw
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