gabriel / muse public
test_cmd_auth_keygen_register.py python
372 lines 15.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for ``muse auth keygen`` and ``muse auth register``.
2
3 Covers:
4 - keygen: key generation, file permissions, --force, duplicate key rejection
5 - keygen: public key / fingerprint format
6 - register: full challenge-response flow with a mocked hub
7 - register: token storage in identity.toml
8 - register: error paths (missing key, network errors, bad challenge token)
9 - keypair module: sign / verify round-trip, key loading
10 """
11 from __future__ import annotations
12
13 import base64
14 import hashlib
15 import json
16 import pathlib
17 import stat
18 import unittest.mock
19 import urllib.error
20 import urllib.request
21 import types
22 from typing import TypedDict
23
24 import pytest
25 from tests.cli_test_helper import CliRunner
26
27 from muse.core import keypair as kp_module
28 from muse.core.store import JsonValue
29 from muse.core._types import Manifest
30
31 type _AuthPayload = dict[str, str | None]
32 type _JsonResponse = dict[str, JsonValue]
33
34
35 class _ChallengeResp(TypedDict, total=False):
36 challengeToken: str
37 isNewKey: bool
38 algorithm: str
39
40
41 class _VerifyResp(TypedDict, total=False):
42 token: str
43 handle: str
44 identityId: str
45 isNewIdentity: bool
46 authMethod: str
47
48 cli = None
49 runner = CliRunner()
50
51
52 # ---------------------------------------------------------------------------
53 # Helpers
54 # ---------------------------------------------------------------------------
55
56
57 def _env(tmp_home: pathlib.Path) -> Manifest:
58 """Environment that redirects ~/.muse to a temp directory."""
59 fake_home = tmp_home / "home"
60 fake_home.mkdir(parents=True, exist_ok=True)
61 return {"HOME": str(fake_home)}
62
63
64 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
65 """Redirect pathlib.Path.home() to a temp dir for this test."""
66 fake_home = tmp_path / "home"
67 fake_home.mkdir(parents=True, exist_ok=True)
68 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
69 # Also redirect the module-level constants
70 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
71 from muse.core import identity as id_module
72 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
73 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
74 return fake_home
75
76
77 # ---------------------------------------------------------------------------
78 # keypair module unit tests
79 # ---------------------------------------------------------------------------
80
81
82 class TestKeypairModule:
83 def test_generate_and_load_roundtrip(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
84 _patch_home(monkeypatch, tmp_path)
85 pub_b64, fingerprint = kp_module.generate_keypair("example.com")
86 # Fingerprint is 64 hex chars
87 assert len(fingerprint) == 64
88 assert all(c in "0123456789abcdef" for c in fingerprint)
89 # Public key is base64url without padding
90 assert "=" not in pub_b64
91 # Load the key back
92 private_key = kp_module.load_private_key("example.com")
93 assert private_key is not None
94 # Derived public key must match
95 reloaded_pub = kp_module.public_key_to_b64url(private_key.public_key())
96 assert reloaded_pub == pub_b64
97
98 def test_key_file_permissions_0o600(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
99 _patch_home(monkeypatch, tmp_path)
100 kp_module.generate_keypair("example.com")
101 key_path = kp_module.key_path_for("example.com")
102 mode = stat.S_IMODE(key_path.stat().st_mode)
103 assert mode == 0o600
104
105 def test_keys_dir_permissions_0o700(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
106 _patch_home(monkeypatch, tmp_path)
107 kp_module.generate_keypair("example.com")
108 keys_dir = kp_module._KEYS_DIR
109 mode = stat.S_IMODE(keys_dir.stat().st_mode)
110 assert mode == 0o700
111
112 def test_load_nonexistent_key_returns_none(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
113 _patch_home(monkeypatch, tmp_path)
114 assert kp_module.load_private_key("no.such.host") is None
115
116 def test_sign_verify_roundtrip(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
117 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
118 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
119
120 _patch_home(monkeypatch, tmp_path)
121 kp_module.generate_keypair("sign-test.example")
122 private_key = kp_module.load_private_key("sign-test.example")
123 assert private_key is not None
124
125 nonce = b"\x01\x02\x03" * 10
126 sig_b64 = kp_module.sign_bytes(private_key, nonce)
127 # Verify the signature independently
128 sig_bytes = base64.urlsafe_b64decode(sig_b64 + "==")
129 public_key: Ed25519PublicKey = private_key.public_key()
130 public_key.verify(sig_bytes, nonce) # does not raise
131
132 def test_fingerprint_matches_sha256(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
133 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
134
135 _patch_home(monkeypatch, tmp_path)
136 kp_module.generate_keypair("fp-test.example")
137 private_key = kp_module.load_private_key("fp-test.example")
138 assert private_key is not None
139 public_key = private_key.public_key()
140 raw = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
141 expected = hashlib.sha256(raw).hexdigest()
142 assert kp_module.public_key_fingerprint(public_key) == expected
143
144 def test_hostname_sanitisation_colon_port(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
145 _patch_home(monkeypatch, tmp_path)
146 kp_module.generate_keypair("localhost:10003")
147 path = kp_module.key_path_for("localhost:10003")
148 # No colon in filename
149 assert ":" not in path.name
150 assert path.exists()
151
152 def test_generate_overwrites_existing_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
153 _patch_home(monkeypatch, tmp_path)
154 pub1, _ = kp_module.generate_keypair("overwrite.test")
155 pub2, _ = kp_module.generate_keypair("overwrite.test")
156 # Keys should differ (new random key generated)
157 assert pub1 != pub2
158
159
160 # ---------------------------------------------------------------------------
161 # muse auth keygen CLI tests
162 # ---------------------------------------------------------------------------
163
164
165 class TestAuthKeygenCLI:
166 HUB = "http://localhost:10003"
167
168 def test_generates_key_successfully(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
169 _patch_home(monkeypatch, tmp_path)
170 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
171 assert result.exit_code == 0
172 assert "Ed25519 keypair generated" in result.output
173 assert "Private key:" in result.output
174 assert "Fingerprint" in result.output
175
176 def test_key_file_created(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
177 _patch_home(monkeypatch, tmp_path)
178 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
179 key_path = kp_module.key_path_for("localhost:10003")
180 assert key_path.exists()
181
182 def test_force_flag_overwrites(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
183 _patch_home(monkeypatch, tmp_path)
184 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
185 r1 = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
186 # Without --force, second keygen should fail
187 assert r1.exit_code != 0
188 r2 = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--force"], catch_exceptions=False)
189 assert r2.exit_code == 0
190
191 def test_no_hub_fails(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
192 _patch_home(monkeypatch, tmp_path)
193 # chdir to a directory with no hub config so get_hub_url(None) returns None.
194 # MUSE_REPO_ROOT only affects find_repo_root(), not get_hub_url().
195 monkeypatch.chdir(tmp_path)
196 result = runner.invoke(cli, ["auth", "keygen"])
197 assert result.exit_code != 0
198
199 def test_label_shown_in_output(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
200 _patch_home(monkeypatch, tmp_path)
201 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--label", "My Laptop"],
202 catch_exceptions=False)
203 assert result.exit_code == 0
204 assert "My Laptop" in result.output
205
206
207 # ---------------------------------------------------------------------------
208 # muse auth register CLI tests (hub is mocked)
209 # ---------------------------------------------------------------------------
210
211
212 class TestAuthRegisterCLI:
213 HUB = "http://localhost:10003"
214
215 def _setup_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> tuple[str, str]:
216 _patch_home(monkeypatch, tmp_path)
217 pub_b64, fingerprint = kp_module.generate_keypair("localhost:10003")
218 return pub_b64, fingerprint
219
220 def _mock_hub(
221 self,
222 monkeypatch: pytest.MonkeyPatch,
223 nonce_hex: str,
224 challenge_resp: _ChallengeResp | None = None,
225 verify_resp: _VerifyResp | None = None,
226 ) -> None:
227 """Patch urllib.request.urlopen to simulate hub challenge-response."""
228 _challenge_resp = challenge_resp or {
229 "challengeToken": nonce_hex,
230 "isNewKey": True,
231 "algorithm": "ed25519",
232 }
233 _verify_resp = verify_resp or {
234 "handle": "alice",
235 "identityId": "id-123",
236 "isNewIdentity": True,
237 "authMethod": "ed25519",
238 }
239 call_count = 0
240
241 class FakeResponse:
242 def __init__(self, data: bytes) -> None:
243 self._data = data
244
245 def read(self, n: int = -1) -> bytes:
246 return self._data[:n] if n >= 0 else self._data
247
248 def __enter__(self) -> "FakeResponse":
249 return self
250
251 def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None) -> None:
252 pass
253
254 def fake_urlopen(req: urllib.request.Request, timeout: int = 30) -> FakeResponse:
255 nonlocal call_count
256 call_count += 1
257 if call_count == 1:
258 return FakeResponse(json.dumps(_challenge_resp).encode())
259 return FakeResponse(json.dumps(_verify_resp).encode())
260
261 import muse.cli.commands.auth as auth_mod
262 monkeypatch.setattr(auth_mod, "_json_post_raw", lambda base, path, payload: _challenge_resp if "challenge" in path else _verify_resp)
263
264 def test_full_registration_stores_identity(
265 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
266 ) -> None:
267 self._setup_key(monkeypatch, tmp_path)
268 import secrets
269 nonce_hex = secrets.token_hex(32)
270 self._mock_hub(monkeypatch, nonce_hex)
271
272 result = runner.invoke(
273 cli,
274 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
275 catch_exceptions=False,
276 )
277 assert result.exit_code == 0
278 assert "alice" in result.output
279
280 # Ed25519 identity must be persisted
281 from muse.core.identity import load_identity
282 entry = load_identity(self.HUB)
283 assert entry is not None
284 assert entry.get("handle") == "alice"
285 assert entry.get("key_path") is not None
286 assert "token" not in entry
287
288 def test_no_key_fails_gracefully(
289 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
290 ) -> None:
291 _patch_home(monkeypatch, tmp_path)
292 result = runner.invoke(
293 cli,
294 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
295 )
296 assert result.exit_code != 0
297 assert "keygen" in result.output.lower() or "no ed25519 key" in result.output.lower()
298
299 def test_new_key_without_handle_fails(
300 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
301 ) -> None:
302 self._setup_key(monkeypatch, tmp_path)
303 import muse.cli.commands.auth as auth_mod
304
305 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
306 if "challenge" in path:
307 return {
308 "challengeToken": "ab" * 32,
309 "isNewKey": True,
310 "algorithm": "ed25519",
311 }
312 return {} # should not reach here
313
314 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
315 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
316 assert result.exit_code != 0
317 assert "--handle" in result.output
318
319 def test_agent_flag_marks_identity_as_agent(
320 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
321 ) -> None:
322 self._setup_key(monkeypatch, tmp_path)
323 import muse.cli.commands.auth as auth_mod
324 import secrets
325
326 nonce_hex = secrets.token_hex(32)
327
328 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
329 if "challenge" in path:
330 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
331 return {"handle": "bot", "identityId": "id-bot", "isNewIdentity": False, "authMethod": "ed25519"}
332
333 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
334 runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "bot", "--agent"], catch_exceptions=False)
335
336 from muse.core.identity import load_identity
337 entry = load_identity(self.HUB)
338 assert entry is not None
339 assert entry.get("type") == "agent"
340
341 def test_bad_challenge_token_fails(
342 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
343 ) -> None:
344 self._setup_key(monkeypatch, tmp_path)
345 import muse.cli.commands.auth as auth_mod
346
347 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
348 return {"challengeToken": "not-valid-hex!", "isNewKey": False, "algorithm": "ed25519"}
349
350 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
351 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "alice"])
352 assert result.exit_code != 0
353
354 def test_empty_verify_response_fails(
355 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
356 ) -> None:
357 """Verify response with no handle and no --handle fallback fails."""
358 self._setup_key(monkeypatch, tmp_path)
359 import muse.cli.commands.auth as auth_mod
360 import secrets
361
362 nonce_hex = secrets.token_hex(32)
363
364 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
365 if "challenge" in path:
366 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
367 return {} # No handle field
368
369 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
370 # No --handle flag, and hub returns no handle → must fail
371 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
372 assert result.exit_code != 0
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago