test_auth_rotate.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 rotate`` — HD key rotation (HIGH-4). |
| 2 | |
| 3 | Key rotation derives a new Ed25519 identity key at index+1 in the HD path |
| 4 | from the OS keychain mnemonic, and updates identity.toml. No PEM is written. |
| 5 | The operator then re-registers with the hub using the new fingerprint. |
| 6 | |
| 7 | The rotation index is the 6th path component (0-indexed): |
| 8 | m/1075233755'/0'/0'/0'/0'/N' |
| 9 | └── N=0 current, N=1 first rotation, … |
| 10 | |
| 11 | Passphrase delivery uses ``--passphrase-fd N`` (pipe fd) or |
| 12 | ``MUSE_BIP39_PASSPHRASE`` env var — never ``--passphrase PHRASE`` (that |
| 13 | would expose the secret in ``ps aux``). |
| 14 | |
| 15 | Coverage |
| 16 | -------- |
| 17 | I Basic rotation |
| 18 | I1 rotate produces a different fingerprint than the original key |
| 19 | I2 the new hd_path has rotation index incremented by 1 |
| 20 | I3 two rotations increment the index by 2 |
| 21 | I4 same mnemonic → same rotated fingerprint (deterministic) |
| 22 | |
| 23 | II CLI flags |
| 24 | II1 --json emits valid JSON with expected fields |
| 25 | II2 --passphrase-fd flows through to seed derivation |
| 26 | II3 MUSE_BIP39_PASSPHRASE env var works for rotate |
| 27 | |
| 28 | III Guard rails |
| 29 | III1 rotate without prior keygen exits non-zero with a clear error |
| 30 | III2 rotate writes no PEM file |
| 31 | III3 hd_path in identity.toml reflects the new rotation index |
| 32 | """ |
| 33 | |
| 34 | from __future__ import annotations |
| 35 | |
| 36 | import json |
| 37 | import os |
| 38 | import pathlib |
| 39 | |
| 40 | import pytest |
| 41 | |
| 42 | from tests.cli_test_helper import CliRunner |
| 43 | from muse.core import keypair as kp_module |
| 44 | from muse.core import identity as id_module |
| 45 | |
| 46 | runner = CliRunner() |
| 47 | |
| 48 | _HUB = "https://localhost:1337" |
| 49 | _MNEMONIC = ( |
| 50 | "abandon abandon abandon abandon abandon abandon abandon abandon " |
| 51 | "abandon abandon abandon about" |
| 52 | ) |
| 53 | |
| 54 | |
| 55 | # --------------------------------------------------------------------------- |
| 56 | # Fixtures |
| 57 | # --------------------------------------------------------------------------- |
| 58 | |
| 59 | |
| 60 | @pytest.fixture() |
| 61 | def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: |
| 62 | fake_home = tmp_path / "home" |
| 63 | fake_home.mkdir(parents=True, exist_ok=True) |
| 64 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 65 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 66 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 67 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 68 | monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) |
| 69 | _kc: dict[str, str] = {} |
| 70 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 71 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 72 | monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) |
| 73 | monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) |
| 74 | return fake_home |
| 75 | |
| 76 | |
| 77 | @pytest.fixture() |
| 78 | def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str: |
| 79 | from muse.core import bip39 as bip39_mod |
| 80 | monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) |
| 81 | return _MNEMONIC |
| 82 | |
| 83 | |
| 84 | def _pipe_passphrase(passphrase: str) -> int: |
| 85 | """Write *passphrase* into a pipe; return the read-end fd.""" |
| 86 | r_fd, w_fd = os.pipe() |
| 87 | os.write(w_fd, passphrase.encode()) |
| 88 | os.close(w_fd) |
| 89 | return r_fd |
| 90 | |
| 91 | |
| 92 | def _keygen(extra: list[str] | None = None): |
| 93 | return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or [])) |
| 94 | |
| 95 | |
| 96 | def _rotate(extra: list[str] | None = None): |
| 97 | return runner.invoke( |
| 98 | None, |
| 99 | ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), |
| 100 | ) |
| 101 | |
| 102 | |
| 103 | def _fp(result) -> str: |
| 104 | return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr] |
| 105 | |
| 106 | |
| 107 | def _hd_path(result) -> str: |
| 108 | return json.loads(result.output.splitlines()[0])["hd_path"] # type: ignore[union-attr] |
| 109 | |
| 110 | |
| 111 | def _rotation_index(hd_path: str) -> int: |
| 112 | """Parse the rotation index (6th component) from a muse hd_path string.""" |
| 113 | # e.g. "m/1075233755'/0'/0'/0'/0'/2'" → 2 |
| 114 | parts = hd_path.split("/") |
| 115 | return int(parts[-1].rstrip("'")) |
| 116 | |
| 117 | |
| 118 | # --------------------------------------------------------------------------- |
| 119 | # I Basic rotation |
| 120 | # --------------------------------------------------------------------------- |
| 121 | |
| 122 | |
| 123 | class TestRotateBasic: |
| 124 | def test_I1_rotate_produces_different_fingerprint( |
| 125 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 126 | ) -> None: |
| 127 | """I1: rotated key has a different fingerprint than the original.""" |
| 128 | r_keygen = _keygen() |
| 129 | assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] |
| 130 | fp_original = _fp(r_keygen) |
| 131 | |
| 132 | r_rotate = _rotate() |
| 133 | assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr] |
| 134 | fp_rotated = _fp(r_rotate) |
| 135 | |
| 136 | assert fp_original != fp_rotated, ( |
| 137 | "Rotated key must have a different fingerprint than the original" |
| 138 | ) |
| 139 | |
| 140 | def test_I2_rotate_increments_index( |
| 141 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 142 | ) -> None: |
| 143 | """I2: the new hd_path has rotation index = old index + 1.""" |
| 144 | r_keygen = _keygen() |
| 145 | assert r_keygen.exit_code == 0 |
| 146 | original_path = _hd_path(r_keygen) |
| 147 | original_index = _rotation_index(original_path) |
| 148 | |
| 149 | r_rotate = _rotate() |
| 150 | assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr] |
| 151 | rotated_path = _hd_path(r_rotate) |
| 152 | rotated_index = _rotation_index(rotated_path) |
| 153 | |
| 154 | assert rotated_index == original_index + 1, ( |
| 155 | f"Expected rotation index {original_index + 1}, got {rotated_index}" |
| 156 | ) |
| 157 | |
| 158 | def test_I3_two_rotations_increment_twice( |
| 159 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 160 | ) -> None: |
| 161 | """I3: a second rotation increments the index again.""" |
| 162 | _keygen() |
| 163 | _rotate() |
| 164 | r2 = _rotate() |
| 165 | assert r2.exit_code == 0, r2.output # type: ignore[union-attr] |
| 166 | assert _rotation_index(_hd_path(r2)) == 2 |
| 167 | |
| 168 | def test_I4_rotation_is_deterministic( |
| 169 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 170 | ) -> None: |
| 171 | """I4: same mnemonic → same rotated fingerprint on every call.""" |
| 172 | _keygen() |
| 173 | r1 = _rotate() |
| 174 | assert r1.exit_code == 0 |
| 175 | |
| 176 | # Re-key back to index 0, then rotate again |
| 177 | r_keygen2 = runner.invoke( |
| 178 | None, |
| 179 | ["auth", "recover", "--hub", _HUB, "--force", "--json"], |
| 180 | input=_MNEMONIC + "\n", |
| 181 | ) |
| 182 | assert r_keygen2.exit_code == 0 |
| 183 | |
| 184 | r2 = _rotate() |
| 185 | assert r2.exit_code == 0 |
| 186 | |
| 187 | assert _fp(r1) == _fp(r2), "Same mnemonic must always rotate to the same fingerprint" |
| 188 | |
| 189 | |
| 190 | # --------------------------------------------------------------------------- |
| 191 | # II CLI flags |
| 192 | # --------------------------------------------------------------------------- |
| 193 | |
| 194 | |
| 195 | class TestRotateFlags: |
| 196 | def test_II1_json_output_has_expected_fields( |
| 197 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 198 | ) -> None: |
| 199 | """II1: --json output contains status, fingerprint, hd_path, hub.""" |
| 200 | _keygen() |
| 201 | r = _rotate() |
| 202 | assert r.exit_code == 0, r.output # type: ignore[union-attr] |
| 203 | data = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr] |
| 204 | for field in ("status", "fingerprint", "hd_path", "hub"): |
| 205 | assert field in data, f"Missing field {field!r} in rotate JSON output" |
| 206 | assert data["status"] == "ok" |
| 207 | |
| 208 | def test_II2_passphrase_fd_changes_result( |
| 209 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 210 | ) -> None: |
| 211 | """II2: --passphrase-fd flows through to mnemonic_to_seed in rotate.""" |
| 212 | _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) |
| 213 | r_with = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))]) |
| 214 | assert r_with.exit_code == 0, r_with.output # type: ignore[union-attr] |
| 215 | |
| 216 | # Rotate again from index 0 without passphrase — must differ |
| 217 | runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n") |
| 218 | r_without = _rotate() |
| 219 | assert r_without.exit_code == 0 |
| 220 | |
| 221 | assert _fp(r_with) != _fp(r_without), ( |
| 222 | "rotate with passphrase must produce a different fingerprint than without" |
| 223 | ) |
| 224 | |
| 225 | def test_II3_env_var_passphrase_works( |
| 226 | self, isolated: pathlib.Path, fixed_mnemonic: str, |
| 227 | monkeypatch: pytest.MonkeyPatch, |
| 228 | ) -> None: |
| 229 | """II3: MUSE_BIP39_PASSPHRASE env var is respected by rotate.""" |
| 230 | _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) |
| 231 | r_flag = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))]) |
| 232 | assert r_flag.exit_code == 0 |
| 233 | |
| 234 | runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n") |
| 235 | monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret") |
| 236 | r_env = _rotate() |
| 237 | assert r_env.exit_code == 0 |
| 238 | |
| 239 | assert _fp(r_flag) == _fp(r_env) |
| 240 | |
| 241 | |
| 242 | # --------------------------------------------------------------------------- |
| 243 | # III Guard rails |
| 244 | # --------------------------------------------------------------------------- |
| 245 | |
| 246 | |
| 247 | class TestRotateGuards: |
| 248 | def test_III1_rotate_without_prior_keygen_fails( |
| 249 | self, isolated: pathlib.Path |
| 250 | ) -> None: |
| 251 | """III1: rotate with no existing identity exits non-zero with a clear error.""" |
| 252 | r = _rotate() |
| 253 | assert r.exit_code != 0, "Expected non-zero exit when no identity exists" |
| 254 | |
| 255 | def test_III2_rotate_writes_no_pem( |
| 256 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 257 | ) -> None: |
| 258 | """III2: rotate must not write any *.pem file.""" |
| 259 | _keygen() |
| 260 | _rotate() |
| 261 | keys_dir = isolated / ".muse" / "keys" |
| 262 | pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] |
| 263 | assert pem_files == [], f"PEM files found after rotate: {pem_files}" |
| 264 | |
| 265 | def test_III3_identity_toml_reflects_new_index( |
| 266 | self, isolated: pathlib.Path, fixed_mnemonic: str |
| 267 | ) -> None: |
| 268 | """III3: identity.toml hd_path is updated to reflect the new rotation index.""" |
| 269 | try: |
| 270 | import tomllib |
| 271 | except ModuleNotFoundError: |
| 272 | import tomli as tomllib # type: ignore[no-reuse-def] |
| 273 | |
| 274 | _keygen() |
| 275 | _rotate() |
| 276 | |
| 277 | toml_path = isolated / ".muse" / "identity.toml" |
| 278 | data = tomllib.loads(toml_path.read_text()) |
| 279 | stored_path = data["localhost:1337"]["hd_path"] |
| 280 | assert _rotation_index(stored_path) == 1, ( |
| 281 | f"identity.toml hd_path must have rotation index 1 after one rotation, " |
| 282 | f"got: {stored_path}" |
| 283 | ) |
File History
2 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
138 days ago