gabriel / musehub public
test_identity_repo_phase1.py python
417 lines 15.7 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 144 days ago
1 """Phase 1 — Identity repo created on registration.
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 - verify_and_authenticate() with a new handle creates {handle}/identity repo
8 - Repo has domain="identity", visibility="private"
9 - Repo has an initial commit on "main" containing identities/{handle}.json
10 - The file content is a valid IdentityRecord with the correct pubkey and handle
11 - A second login (existing key) does NOT create a duplicate repo
12 - register_agent_identity() also creates an identity repo for agents
13 """
14 from __future__ import annotations
15
16 import json
17
18 import pytest
19 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
20 from muse.core.types import blob_id, encode_pubkey
21 from muse.plugins.identity.records import (
22 identity_path,
23 record_from_bytes,
24 )
25 from sqlalchemy import select
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.core.genesis import compute_identity_id, compute_key_id
29 from musehub.crypto.keys import b64url_encode, key_fingerprint
30 from musehub.db import musehub_models as db
31 from musehub.db.musehub_auth_models import MusehubAuthKey
32 from musehub.services.musehub_auth import (
33 create_challenge,
34 verify_and_authenticate,
35 register_agent_identity,
36 )
37
38
39 # ── helpers ───────────────────────────────────────────────────────────────────
40
41
42 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
43 priv = Ed25519PrivateKey.generate()
44 pub = priv.public_key().public_bytes_raw()
45 return priv, pub
46
47
48 def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str:
49 from muse.core.types import encode_sig
50 sig_bytes = priv.sign(bytes.fromhex(nonce_hex))
51 return encode_sig("ed25519", sig_bytes)
52
53
54 async def _register(
55 session: AsyncSession,
56 handle: str,
57 priv: Ed25519PrivateKey,
58 pub: bytes,
59 ):
60 """Full challenge → verify flow for a fresh identity."""
61 public_key_b64 = encode_pubkey("ed25519", pub)
62 fp = key_fingerprint(pub)
63 nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519")
64 sig_b64 = _sign_nonce(priv, nonce)
65 return await verify_and_authenticate(
66 session=session,
67 challenge_token=nonce,
68 public_key_b64=public_key_b64,
69 signature_b64=sig_b64,
70 handle=handle,
71 display_name=handle,
72 label="test-key",
73 )
74
75
76 # ═══════════════════════════════════════════════════════════════════════════════
77 # 1. Identity repo created on registration
78 # ═══════════════════════════════════════════════════════════════════════════════
79
80
81 class TestIdentityRepoCreatedOnRegistration:
82 async def test_registration_creates_identity_repo(
83 self, db_session: AsyncSession
84 ) -> None:
85 """verify_and_authenticate with a new handle must create a {handle}/identity repo."""
86 priv, pub = _keypair()
87 await _register(db_session, "alice", priv, pub)
88
89 result = await db_session.execute(
90 select(db.MusehubRepo).where(
91 db.MusehubRepo.owner == "alice",
92 db.MusehubRepo.slug == "identity",
93 )
94 )
95 repo = result.scalar_one_or_none()
96 assert repo is not None, (
97 "Expected {handle}/identity repo to be created on registration, "
98 "but no MusehubRepo row found with owner='alice', slug='identity'."
99 )
100
101 async def test_identity_repo_has_correct_domain(
102 self, db_session: AsyncSession
103 ) -> None:
104 priv, pub = _keypair()
105 await _register(db_session, "bob", priv, pub)
106
107 result = await db_session.execute(
108 select(db.MusehubRepo).where(
109 db.MusehubRepo.owner == "bob",
110 db.MusehubRepo.slug == "identity",
111 )
112 )
113 repo = result.scalar_one_or_none()
114 assert repo is not None
115 assert repo.domain_id == "identity", (
116 f"Expected domain_id='identity', got {repo.domain_id!r}."
117 )
118
119 async def test_identity_repo_is_private(
120 self, db_session: AsyncSession
121 ) -> None:
122 priv, pub = _keypair()
123 await _register(db_session, "carol", priv, pub)
124
125 result = await db_session.execute(
126 select(db.MusehubRepo).where(
127 db.MusehubRepo.owner == "carol",
128 db.MusehubRepo.slug == "identity",
129 )
130 )
131 repo = result.scalar_one_or_none()
132 assert repo is not None
133 assert repo.visibility == "private", (
134 f"Identity repo must be private, got visibility={repo.visibility!r}."
135 )
136
137 async def test_login_does_not_create_duplicate_repo(
138 self, db_session: AsyncSession
139 ) -> None:
140 """A second verify_and_authenticate with the same key must not create a second repo."""
141 priv, pub = _keypair()
142 await _register(db_session, "dave", priv, pub)
143
144 # Second login with same key
145 public_key_b64 = encode_pubkey("ed25519", pub)
146 fp = key_fingerprint(pub)
147 nonce = await create_challenge(db_session, fingerprint=fp, algorithm="ed25519")
148 sig_b64 = _sign_nonce(priv, nonce)
149 await verify_and_authenticate(
150 session=db_session,
151 challenge_token=nonce,
152 public_key_b64=public_key_b64,
153 signature_b64=sig_b64,
154 handle=None,
155 display_name=None,
156 label=None,
157 )
158
159 result = await db_session.execute(
160 select(db.MusehubRepo).where(
161 db.MusehubRepo.owner == "dave",
162 db.MusehubRepo.slug == "identity",
163 )
164 )
165 repos = result.scalars().all()
166 assert len(repos) == 1, (
167 f"Expected exactly one identity repo for 'dave', found {len(repos)}."
168 )
169
170
171 # ═══════════════════════════════════════════════════════════════════════════════
172 # 2. Initial commit contains identities/{handle}.json
173 # ═══════════════════════════════════════════════════════════════════════════════
174
175
176 class TestIdentityRepoInitialCommit:
177 async def test_identity_repo_has_commit_on_main(
178 self, db_session: AsyncSession
179 ) -> None:
180 """The identity repo must have at least one commit on 'main'."""
181 priv, pub = _keypair()
182 await _register(db_session, "eve", priv, pub)
183
184 repo_result = await db_session.execute(
185 select(db.MusehubRepo).where(
186 db.MusehubRepo.owner == "eve",
187 db.MusehubRepo.slug == "identity",
188 )
189 )
190 repo = repo_result.scalar_one_or_none()
191 assert repo is not None
192
193 branch_result = await db_session.execute(
194 select(db.MusehubBranch).where(
195 db.MusehubBranch.repo_id == repo.repo_id,
196 db.MusehubBranch.name == "main",
197 )
198 )
199 branch = branch_result.scalar_one_or_none()
200 assert branch is not None, "Identity repo must have a 'main' branch."
201 assert branch.head_commit_id is not None, (
202 "Identity repo 'main' branch must have a non-null head_commit_id."
203 )
204
205 async def test_initial_commit_has_identity_record_object(
206 self, db_session: AsyncSession
207 ) -> None:
208 """The initial commit must reference an object at identities/{handle}.json."""
209 priv, pub = _keypair()
210 await _register(db_session, "frank", priv, pub)
211
212 repo_result = await db_session.execute(
213 select(db.MusehubRepo).where(
214 db.MusehubRepo.owner == "frank",
215 db.MusehubRepo.slug == "identity",
216 )
217 )
218 repo = repo_result.scalar_one_or_none()
219 assert repo is not None
220
221 # Find the commit
222 branch_result = await db_session.execute(
223 select(db.MusehubBranch).where(
224 db.MusehubBranch.repo_id == repo.repo_id,
225 db.MusehubBranch.name == "main",
226 )
227 )
228 branch = branch_result.scalar_one_or_none()
229 assert branch is not None
230
231 commit_result = await db_session.execute(
232 select(db.MusehubCommit).where(
233 db.MusehubCommit.commit_id == branch.head_commit_id
234 )
235 )
236 commit = commit_result.scalar_one_or_none()
237 assert commit is not None
238
239 # The commit must reference a snapshot
240 assert commit.snapshot_id is not None, (
241 "Initial identity commit must have a snapshot_id."
242 )
243
244 # The snapshot manifest must include identities/{handle}.json
245 import msgpack
246 snap_result = await db_session.execute(
247 select(db.MusehubSnapshot).where(
248 db.MusehubSnapshot.snapshot_id == commit.snapshot_id
249 )
250 )
251 snap = snap_result.scalar_one_or_none()
252 assert snap is not None, "No MusehubSnapshot row found for commit snapshot_id."
253
254 manifest: dict = msgpack.unpackb(snap.manifest_blob, raw=False)
255 expected_path = identity_path("frank")
256 assert expected_path in manifest, (
257 f"Expected {expected_path!r} in snapshot manifest {list(manifest)!r}. "
258 "The initial commit must include the identity record."
259 )
260
261
262 # ═══════════════════════════════════════════════════════════════════════════════
263 # 3. Identity record content is correct
264 # ═══════════════════════════════════════════════════════════════════════════════
265
266
267 class TestIdentityRecordContent:
268 async def test_identity_record_handle_matches(
269 self, db_session: AsyncSession
270 ) -> None:
271 priv, pub = _keypair()
272 await _register(db_session, "grace", priv, pub)
273
274 record = await _read_identity_record(db_session, "grace")
275 assert record["handle"] == "grace"
276
277 async def test_identity_record_type_is_human(
278 self, db_session: AsyncSession
279 ) -> None:
280 priv, pub = _keypair()
281 await _register(db_session, "heidi", priv, pub)
282
283 record = await _read_identity_record(db_session, "heidi")
284 assert record["type"] == "human"
285
286 async def test_identity_record_pubkey_matches_registered_key(
287 self, db_session: AsyncSession
288 ) -> None:
289 priv, pub = _keypair()
290 await _register(db_session, "ivan", priv, pub)
291
292 expected_pubkey = encode_pubkey("ed25519", pub)
293 record = await _read_identity_record(db_session, "ivan")
294 assert record["pubkey"] == expected_pubkey, (
295 f"IdentityRecord.pubkey {record['pubkey']!r} does not match "
296 f"the registered public key {expected_pubkey!r}."
297 )
298
299 async def test_identity_record_registered_at_is_set(
300 self, db_session: AsyncSession
301 ) -> None:
302 priv, pub = _keypair()
303 await _register(db_session, "judy", priv, pub)
304
305 record = await _read_identity_record(db_session, "judy")
306 assert record.get("registered_at"), (
307 "IdentityRecord.registered_at must be a non-empty ISO-8601 string."
308 )
309
310
311 # ═══════════════════════════════════════════════════════════════════════════════
312 # 4. Agent registration also creates an identity repo
313 # ═══════════════════════════════════════════════════════════════════════════════
314
315
316 class TestAgentIdentityRepo:
317 async def test_agent_registration_creates_identity_repo(
318 self, db_session: AsyncSession
319 ) -> None:
320 """register_agent_identity() must also create an identity repo for agents."""
321 _, pub = _keypair()
322 public_key_b64 = encode_pubkey("ed25519", pub)
323 fp = key_fingerprint(pub)
324
325 await register_agent_identity(
326 session=db_session,
327 handle="test-agent-01",
328 public_key_b64=public_key_b64,
329 fingerprint=fp,
330 algorithm="ed25519",
331 spawned_by="grace",
332 )
333
334 result = await db_session.execute(
335 select(db.MusehubRepo).where(
336 db.MusehubRepo.owner == "test-agent-01",
337 db.MusehubRepo.slug == "identity",
338 )
339 )
340 repo = result.scalar_one_or_none()
341 assert repo is not None, (
342 "register_agent_identity() must create a {handle}/identity repo "
343 "with domain='identity'."
344 )
345 assert repo.domain_id == "identity"
346
347 async def test_agent_identity_record_type_is_agent(
348 self, db_session: AsyncSession
349 ) -> None:
350 _, pub = _keypair()
351 public_key_b64 = encode_pubkey("ed25519", pub)
352 fp = key_fingerprint(pub)
353
354 await register_agent_identity(
355 session=db_session,
356 handle="test-agent-02",
357 public_key_b64=public_key_b64,
358 fingerprint=fp,
359 algorithm="ed25519",
360 spawned_by="grace",
361 )
362
363 record = await _read_identity_record(db_session, "test-agent-02")
364 assert record["type"] == "agent", (
365 f"Expected IdentityRecord.type='agent', got {record['type']!r}."
366 )
367
368
369 # ── helper to read the identity record from the DB ────────────────────────────
370
371
372 async def _read_identity_record(session: AsyncSession, handle: str) -> dict:
373 """Read identities/{handle}.json content from the identity repo's initial commit."""
374 repo_result = await session.execute(
375 select(db.MusehubRepo).where(
376 db.MusehubRepo.owner == handle,
377 db.MusehubRepo.slug == "identity",
378 )
379 )
380 repo = repo_result.scalar_one()
381
382 branch_result = await session.execute(
383 select(db.MusehubBranch).where(
384 db.MusehubBranch.repo_id == repo.repo_id,
385 db.MusehubBranch.name == "main",
386 )
387 )
388 branch = branch_result.scalar_one()
389
390 commit_result = await session.execute(
391 select(db.MusehubCommit).where(
392 db.MusehubCommit.commit_id == branch.head_commit_id
393 )
394 )
395 commit = commit_result.scalar_one()
396
397 import msgpack
398 snap_result = await session.execute(
399 select(db.MusehubSnapshot).where(
400 db.MusehubSnapshot.snapshot_id == commit.snapshot_id
401 )
402 )
403 snap = snap_result.scalar_one()
404 manifest: dict = msgpack.unpackb(snap.manifest_blob, raw=False)
405
406 file_path = identity_path(handle)
407 object_id = manifest[file_path]
408
409 obj_result = await session.execute(
410 select(db.MusehubObject).where(db.MusehubObject.object_id == object_id)
411 )
412 obj = obj_result.scalar_one()
413
414 from pathlib import Path
415 disk_uri = obj.disk_path or obj.storage_uri or ""
416 raw = Path(disk_uri.removeprefix("local://")).read_bytes()
417 return json.loads(raw)
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 144 days ago