test_security_check_hub_scoped.py
python
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠ breaking
19 hours ago
| 1 | """Tests for the ``muse auth security-check`` hub-scoping gate (musehub#221). |
| 2 | |
| 3 | Check 5 (``hub_scoped``) verifies the checked identity's ``hd_path`` has a |
| 4 | hub segment. Unlike check 4 (fingerprint-matches-mnemonic), which honestly |
| 5 | verifies against whichever scheme is actually on file, this check |
| 6 | deliberately fails on a pre-#221 identity — ``security-check`` doubles as |
| 7 | the hub-scoping migration gate, not just PEM/keychain hygiene. |
| 8 | |
| 9 | Coverage |
| 10 | -------- |
| 11 | HS-1 hub_scoped=True and ok=True for a real (post-#221) keygen identity. |
| 12 | HS-2 hub_scoped=False and ok=False for a pre-#221 (six-level) identity. |
| 13 | HS-3 fingerprint_matches_mnemonic still reports honestly (True) for a |
| 14 | pre-#221 identity even though ok=False overall — check 4 and check 5 |
| 15 | are independent facts, not conflated into one. |
| 16 | HS-4 Text-mode output names the remediation command for a failing check. |
| 17 | HS-5 hub_scoped is null when no identity can be checked at all. |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import json |
| 23 | import pathlib |
| 24 | |
| 25 | import pytest |
| 26 | |
| 27 | from tests.cli_test_helper import CliRunner |
| 28 | |
| 29 | _HUB = "https://localhost:1337" |
| 30 | _HOSTNAME = "localhost:1337" |
| 31 | _MNEMONIC = ( |
| 32 | "abandon abandon abandon abandon abandon abandon abandon abandon " |
| 33 | "abandon abandon abandon about" |
| 34 | ) |
| 35 | |
| 36 | runner = CliRunner() |
| 37 | |
| 38 | |
| 39 | def _patch_env(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: |
| 40 | import muse.core.keypair as kp_module |
| 41 | import muse.core.identity as id_module |
| 42 | |
| 43 | fake_home = tmp_path / "home" |
| 44 | fake_home.mkdir(parents=True, exist_ok=True) |
| 45 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 46 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 47 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 48 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 49 | |
| 50 | _kc: dict[str, str] = {} |
| 51 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 52 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 53 | monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) |
| 54 | monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) |
| 55 | monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) |
| 56 | return fake_home |
| 57 | |
| 58 | |
| 59 | def _save_legacy_identity(mnemonic: str = _MNEMONIC) -> str: |
| 60 | """Write a pre-#221 (six-level, hub=None) identity and return its fingerprint.""" |
| 61 | from muse.core.identity import save_identity |
| 62 | from muse.core.bip39 import mnemonic_to_seed |
| 63 | from muse.core.keypair import derive_hd_public_info |
| 64 | |
| 65 | seed = mnemonic_to_seed(mnemonic) |
| 66 | _, fp = derive_hd_public_info(seed) # hub=None -> legacy six-level path |
| 67 | save_identity(_HUB, { |
| 68 | "type": "human", |
| 69 | "handle": "gabriel", |
| 70 | "algorithm": "ed25519", |
| 71 | "fingerprint": fp, |
| 72 | "hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'", |
| 73 | }, mnemonic=mnemonic) |
| 74 | return fp |
| 75 | |
| 76 | |
| 77 | class TestHubScopedTrue: |
| 78 | def test_HS_1_real_keygen_is_hub_scoped_and_ok( |
| 79 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 80 | ) -> None: |
| 81 | _patch_env(monkeypatch, tmp_path) |
| 82 | runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) |
| 83 | result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) |
| 84 | payload = json.loads(result.output.splitlines()[0]) |
| 85 | assert payload["hub_scoped"] is True |
| 86 | assert payload["ok"] is True |
| 87 | assert result.exit_code == 0 |
| 88 | |
| 89 | |
| 90 | class TestHubScopedFalse: |
| 91 | def test_HS_2_legacy_identity_is_not_hub_scoped_and_fails( |
| 92 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 93 | ) -> None: |
| 94 | _patch_env(monkeypatch, tmp_path) |
| 95 | _save_legacy_identity() |
| 96 | result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) |
| 97 | payload = json.loads(result.output.splitlines()[0]) |
| 98 | assert payload["hub_scoped"] is False |
| 99 | assert payload["ok"] is False |
| 100 | assert result.exit_code != 0 |
| 101 | |
| 102 | def test_HS_3_fingerprint_check_is_independently_honest( |
| 103 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 104 | ) -> None: |
| 105 | """A legacy identity's stored fingerprint still genuinely matches its |
| 106 | own (legacy-scheme) mnemonic derivation — check 4 doesn't lie just |
| 107 | because check 5 is failing.""" |
| 108 | _patch_env(monkeypatch, tmp_path) |
| 109 | _save_legacy_identity() |
| 110 | result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) |
| 111 | payload = json.loads(result.output.splitlines()[0]) |
| 112 | assert payload["fingerprint_matches_mnemonic"] is True |
| 113 | assert payload["hub_scoped"] is False |
| 114 | assert payload["ok"] is False |
| 115 | |
| 116 | def test_HS_4_text_output_names_migrate_command( |
| 117 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 118 | ) -> None: |
| 119 | _patch_env(monkeypatch, tmp_path) |
| 120 | _save_legacy_identity() |
| 121 | result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB]) |
| 122 | assert "muse migrate hub-scoping" in result.stderr |
| 123 | |
| 124 | |
| 125 | class TestHubScopedNull: |
| 126 | def test_HS_5_no_identity_yields_null( |
| 127 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 128 | ) -> None: |
| 129 | _patch_env(monkeypatch, tmp_path) |
| 130 | result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) |
| 131 | payload = json.loads(result.output.splitlines()[0]) |
| 132 | assert payload["hub_scoped"] is None |
| 133 | assert payload["ok"] is False |
File History
1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠
19 hours ago