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