test_cmd_auth_keygen_register.py
python
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