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