gabriel / muse public
test_cmd_auth_keygen_register.py python
337 lines 13.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 json
15 import pathlib
16 import unittest.mock
17 import urllib.error
18 import urllib.request
19 import types
20 from typing import TypedDict
21
22 import pytest
23 from tests.cli_test_helper import CliRunner
24
25 from muse.core import keypair as kp_module
26 from muse.core.store import JsonValue
27 from muse.core._types import Manifest
28
29 type _AuthPayload = dict[str, str | None]
30 type _JsonResponse = dict[str, JsonValue]
31
32
33 class _ChallengeResp(TypedDict, total=False):
34 challengeToken: str
35 isNewKey: bool
36 algorithm: str
37
38
39 class _VerifyResp(TypedDict, total=False):
40 token: str
41 handle: str
42 identityId: str
43 isNewIdentity: bool
44 authMethod: str
45
46 cli = None
47 runner = CliRunner()
48
49
50 # ---------------------------------------------------------------------------
51 # Helpers
52 # ---------------------------------------------------------------------------
53
54
55 def _env(tmp_home: pathlib.Path) -> Manifest:
56 """Environment that redirects ~/.muse to a temp directory."""
57 fake_home = tmp_home / "home"
58 fake_home.mkdir(parents=True, exist_ok=True)
59 return {"HOME": str(fake_home)}
60
61
62 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
63 """Redirect pathlib.Path.home() to a temp dir for this test."""
64 fake_home = tmp_path / "home"
65 fake_home.mkdir(parents=True, exist_ok=True)
66 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
67 # Also redirect the module-level constants
68 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
69 from muse.core import identity as id_module
70 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
71 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
72 return fake_home
73
74
75 # ---------------------------------------------------------------------------
76 # keypair module unit tests
77 # ---------------------------------------------------------------------------
78
79 # Fixed test seeds — deterministic, unique per test scenario.
80 _SEED_A = b"\x01" * 64
81 _SEED_C = b"\x03" * 64
82 _SEED_D = b"\x04" * 64
83
84
85 class TestKeypairModule:
86 def test_fingerprint_matches_sha256(self) -> None:
87 from muse.core._types import public_key_fingerprint as _fp
88 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
89 import base64
90
91 pub_b64, fingerprint = kp_module.derive_hd_public_info(_SEED_C)
92 assert fingerprint.startswith("sha256:")
93 assert len(fingerprint) == 71
94 raw = base64.urlsafe_b64decode(pub_b64 + "==")
95 assert fingerprint == _fp(raw)
96
97 def test_different_seeds_produce_different_keys(self) -> None:
98 pub1, _ = kp_module.derive_hd_public_info(_SEED_A)
99 pub2, _ = kp_module.derive_hd_public_info(_SEED_D)
100 assert pub1 != pub2
101
102
103 # ---------------------------------------------------------------------------
104 # muse auth keygen CLI tests
105 # ---------------------------------------------------------------------------
106
107
108 class TestAuthKeygenCLI:
109 HUB = "https://localhost:1337"
110
111 def test_generates_key_successfully(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
112 _patch_home(monkeypatch, tmp_path)
113 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
114 assert result.exit_code == 0
115 assert "Ed25519 keypair generated" in result.output
116 assert "Fingerprint" in result.output
117
118 def test_no_pem_file_created(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
119 """keygen must not write a PEM file — key is derived from mnemonic at sign time."""
120 _patch_home(monkeypatch, tmp_path)
121 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
122 keys_dir = tmp_path / "home" / ".muse" / "keys"
123 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
124 assert pem_files == [], f"Unexpected PEM files created: {pem_files}"
125
126 def test_force_flag_still_succeeds(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
127 """--force still works (generates a new mnemonic, overwrites identity entry)."""
128 _patch_home(monkeypatch, tmp_path)
129 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
130 r2 = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--force"], catch_exceptions=False)
131 assert r2.exit_code == 0
132
133 def test_no_hub_fails(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
134 _patch_home(monkeypatch, tmp_path)
135 # chdir to a directory with no hub config so get_hub_url(None) returns None.
136 # MUSE_REPO_ROOT only affects find_repo_root(), not get_hub_url().
137 monkeypatch.chdir(tmp_path)
138 result = runner.invoke(cli, ["auth", "keygen"])
139 assert result.exit_code != 0
140
141 def test_label_shown_in_output(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
142 _patch_home(monkeypatch, tmp_path)
143 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--label", "My Laptop"],
144 catch_exceptions=False)
145 assert result.exit_code == 0
146 assert "My Laptop" in result.output
147
148
149 # ---------------------------------------------------------------------------
150 # muse auth register CLI tests (hub is mocked)
151 # ---------------------------------------------------------------------------
152
153
154 _FIXED_MNEMONIC = (
155 "abandon abandon abandon abandon abandon abandon abandon abandon "
156 "abandon abandon abandon about"
157 )
158
159
160 class TestAuthRegisterCLI:
161 HUB = "https://localhost:1337"
162
163 def _setup_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> tuple[str, str]:
164 """Run keygen so an identity entry with hd_path exists, and seed keychain."""
165 import muse.core.bip39 as bip39_mod
166 import muse.core.identity as id_module
167
168 _patch_home(monkeypatch, tmp_path)
169
170 _kc: dict[str, str] = {}
171 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
172 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
173 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
174 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
175 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _FIXED_MNEMONIC)
176
177 result = runner.invoke(None, ["auth", "keygen", "--hub", self.HUB])
178 assert result.exit_code == 0, f"keygen setup failed: {result.output}"
179
180 entry = id_module.load_identity(self.HUB)
181 assert entry is not None
182 return entry.get("public_key_b64", ""), entry.get("fingerprint", "")
183
184 def _mock_hub(
185 self,
186 monkeypatch: pytest.MonkeyPatch,
187 nonce_hex: str,
188 challenge_resp: _ChallengeResp | None = None,
189 verify_resp: _VerifyResp | None = None,
190 ) -> None:
191 """Patch urllib.request.urlopen to simulate hub challenge-response."""
192 _challenge_resp = challenge_resp or {
193 "challengeToken": nonce_hex,
194 "isNewKey": True,
195 "algorithm": "ed25519",
196 }
197 _verify_resp = verify_resp or {
198 "handle": "alice",
199 "identityId": "id-123",
200 "isNewIdentity": True,
201 "authMethod": "ed25519",
202 }
203 call_count = 0
204
205 class FakeResponse:
206 def __init__(self, data: bytes) -> None:
207 self._data = data
208
209 def read(self, n: int = -1) -> bytes:
210 return self._data[:n] if n >= 0 else self._data
211
212 def __enter__(self) -> "FakeResponse":
213 return self
214
215 def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None) -> None:
216 pass
217
218 def fake_urlopen(req: urllib.request.Request, timeout: int = 30) -> FakeResponse:
219 nonlocal call_count
220 call_count += 1
221 if call_count == 1:
222 return FakeResponse(json.dumps(_challenge_resp).encode())
223 return FakeResponse(json.dumps(_verify_resp).encode())
224
225 import muse.cli.commands.auth as auth_mod
226 monkeypatch.setattr(auth_mod, "_json_post_raw", lambda base, path, payload: _challenge_resp if "challenge" in path else _verify_resp)
227
228 def test_full_registration_stores_identity(
229 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
230 ) -> None:
231 self._setup_key(monkeypatch, tmp_path)
232 import secrets
233 nonce_hex = secrets.token_hex(32)
234 self._mock_hub(monkeypatch, nonce_hex)
235
236 result = runner.invoke(
237 cli,
238 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
239 catch_exceptions=False,
240 )
241 assert result.exit_code == 0
242 assert "alice" in result.output
243
244 # Ed25519 identity must be persisted
245 from muse.core.identity import load_identity
246 entry = load_identity(self.HUB)
247 assert entry is not None
248 assert entry.get("handle") == "alice"
249 assert entry.get("hd_path") is not None
250 assert "key_path" not in entry
251 assert "token" not in entry
252
253 def test_no_key_fails_gracefully(
254 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
255 ) -> None:
256 _patch_home(monkeypatch, tmp_path)
257 result = runner.invoke(
258 cli,
259 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
260 )
261 assert result.exit_code != 0
262 assert "keygen" in result.output.lower() or "no ed25519 key" in result.output.lower()
263
264 def test_new_key_without_handle_fails(
265 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
266 ) -> None:
267 self._setup_key(monkeypatch, tmp_path)
268 import muse.cli.commands.auth as auth_mod
269
270 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
271 if "challenge" in path:
272 return {
273 "challengeToken": "ab" * 32,
274 "isNewKey": True,
275 "algorithm": "ed25519",
276 }
277 return {} # should not reach here
278
279 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
280 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
281 assert result.exit_code != 0
282 assert "--handle" in result.output
283
284 def test_agent_flag_marks_identity_as_agent(
285 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
286 ) -> None:
287 self._setup_key(monkeypatch, tmp_path)
288 import muse.cli.commands.auth as auth_mod
289 import secrets
290
291 nonce_hex = secrets.token_hex(32)
292
293 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
294 if "challenge" in path:
295 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
296 return {"handle": "bot", "identityId": "id-bot", "isNewIdentity": False, "authMethod": "ed25519"}
297
298 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
299 runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "bot", "--agent"], catch_exceptions=False)
300
301 from muse.core.identity import load_identity
302 entry = load_identity(self.HUB)
303 assert entry is not None
304 assert entry.get("type") == "agent"
305
306 def test_bad_challenge_token_fails(
307 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
308 ) -> None:
309 self._setup_key(monkeypatch, tmp_path)
310 import muse.cli.commands.auth as auth_mod
311
312 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
313 return {"challengeToken": "not-valid-hex!", "isNewKey": False, "algorithm": "ed25519"}
314
315 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
316 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "alice"])
317 assert result.exit_code != 0
318
319 def test_empty_verify_response_fails(
320 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
321 ) -> None:
322 """Verify response with no handle and no --handle fallback fails."""
323 self._setup_key(monkeypatch, tmp_path)
324 import muse.cli.commands.auth as auth_mod
325 import secrets
326
327 nonce_hex = secrets.token_hex(32)
328
329 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
330 if "challenge" in path:
331 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
332 return {} # No handle field
333
334 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
335 # No --handle flag, and hub returns no handle → must fail
336 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
337 assert result.exit_code != 0
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago