"""Tests for user identity config behaviour. User identity — handle, type, display_name, email — lives exclusively in ~/.muse/identity.toml, keyed per hub hostname. config.toml has no [user] section. Coverage -------- - _dump_toml does not emit a [user] section. - set_config_value("user.*") raises ValueError / exits non-zero. - get_config_value("user.type") reads from identity.toml. - get_config_value("user.handle") reads from identity.toml. - A [user] section in config.toml is never loaded (not part of the schema). - muse config set user.type agent --json exits non-zero with structured error. - muse config get user.type --json returns identity.toml value. - commit --author falls back to identity.toml handle. """ from __future__ import annotations import json import os import pathlib import tempfile from unittest.mock import patch import pytest from muse.core.paths import config_toml_path from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: saved = os.getcwd() try: os.chdir(tmp_path) runner.invoke(None, ["init"]) finally: os.chdir(saved) return tmp_path def _write_config(repo: pathlib.Path, content: str) -> None: config_toml_path(repo).write_text(content, encoding="utf-8") def _write_identity(identity_path: pathlib.Path, content: str) -> None: identity_path.parent.mkdir(parents=True, exist_ok=True) identity_path.write_text(content, encoding="utf-8") # --------------------------------------------------------------------------- # 1. _dump_toml never writes [user] # --------------------------------------------------------------------------- class TestDumpTomlNoUserSection: def test_dump_toml_never_emits_user_section(self) -> None: from muse.cli.config import _dump_toml config = { "user": {"handle": "gabriel", "type": "human", "email": "g@example.com"}, "hub": {"url": "https://localhost:1337"}, } output = _dump_toml(config) assert "[user]" not in output def test_dump_toml_without_user_key_is_unchanged(self) -> None: from muse.cli.config import _dump_toml config = {"hub": {"url": "https://localhost:1337"}} output = _dump_toml(config) assert "[user]" not in output assert "localhost:1337" in output def test_writing_config_does_not_persist_user_section( self, tmp_path: pathlib.Path ) -> None: """Round-trip: writing config never leaves a [user] section on disk.""" from muse.cli.config import write_branch_meta import tomllib repo = _make_repo(tmp_path) # Pre-seed a [user] section the old way. _write_config(repo, '[user]\nhandle = "gabriel"\ntype = "agent"\n') # Trigger any config write (branch meta is the simplest). write_branch_meta(repo, "feat/x", intent="test") cp = config_toml_path(repo) with cp.open("rb") as f: cfg = tomllib.load(f) assert "user" not in cfg # --------------------------------------------------------------------------- # 2. set_config_value("user.*") is rejected # --------------------------------------------------------------------------- class TestSetUserConfigRejected: def test_set_user_type_raises(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import set_config_value repo = _make_repo(tmp_path) with pytest.raises((ValueError, SystemExit)): set_config_value("user.type", "agent", repo) def test_set_user_handle_raises(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import set_config_value repo = _make_repo(tmp_path) with pytest.raises((ValueError, SystemExit)): set_config_value("user.handle", "someone", repo) def test_set_user_email_raises(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import set_config_value repo = _make_repo(tmp_path) with pytest.raises((ValueError, SystemExit)): set_config_value("user.email", "x@y.com", repo) def test_cli_set_user_type_exits_nonzero(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _invoke(repo, ["config", "set", "user.type", "agent", "--json"]) assert result.exit_code != 0 def test_cli_set_user_type_json_error_mentions_auth( self, tmp_path: pathlib.Path ) -> None: """The error message should direct the user to muse auth.""" repo = _make_repo(tmp_path) result = _invoke(repo, ["config", "set", "user.type", "agent", "--json"]) assert result.exit_code != 0 try: data = json.loads(result.output.strip().splitlines()[-1]) msg = data.get("message", "") except (json.JSONDecodeError, IndexError): msg = result.output + result.stderr assert "auth" in msg.lower() # --------------------------------------------------------------------------- # 3. get_config_value("user.*") reads from identity.toml # --------------------------------------------------------------------------- class TestGetUserConfigFromIdentity: def _identity_toml(self, handle: str, utype: str = "human") -> str: return ( f'["localhost:1337"]\n' f'type = "{utype}"\n' f'handle = "{handle}"\n' f'algorithm = "ed25519"\n' f'fingerprint = "sha256:abc123"\n' f'hd_path = "m/0\'"\n' ) def test_get_user_type_returns_identity_value( self, tmp_path: pathlib.Path ) -> None: from muse.cli.config import get_config_value from muse.core.identity import get_identity_path repo = _make_repo(tmp_path) _write_config(repo, '[hub]\nurl = "https://localhost:1337"\n') with patch("muse.core.identity._IDENTITY_FILE", tmp_path / "identity.toml"): _write_identity( tmp_path / "identity.toml", self._identity_toml("gabriel", "human") ) result = get_config_value("user.type", repo) assert result == "human" def test_get_user_type_ignores_stale_config_value( self, tmp_path: pathlib.Path ) -> None: """A stale type="agent" in config.toml must NOT be returned.""" from muse.cli.config import get_config_value repo = _make_repo(tmp_path) _write_config( repo, '[hub]\nurl = "https://localhost:1337"\n\n' '[user]\nhandle = "gabriel"\ntype = "agent"\n', ) with patch("muse.core.identity._IDENTITY_FILE", tmp_path / "identity.toml"): _write_identity( tmp_path / "identity.toml", self._identity_toml("gabriel", "human") ) result = get_config_value("user.type", repo) assert result == "human" assert result != "agent" def test_get_user_handle_returns_identity_value( self, tmp_path: pathlib.Path ) -> None: from muse.cli.config import get_config_value repo = _make_repo(tmp_path) _write_config(repo, '[hub]\nurl = "https://localhost:1337"\n') with patch("muse.core.identity._IDENTITY_FILE", tmp_path / "identity.toml"): _write_identity( tmp_path / "identity.toml", self._identity_toml("gabriel") ) result = get_config_value("user.handle", repo) assert result == "gabriel" def test_get_user_type_none_when_no_identity( self, tmp_path: pathlib.Path ) -> None: """No identity registered → get_config_value returns None, not a crash.""" from muse.cli.config import get_config_value repo = _make_repo(tmp_path) _write_config(repo, '[hub]\nurl = "https://localhost:1337"\n') with patch("muse.core.identity._IDENTITY_FILE", tmp_path / "identity.toml"): result = get_config_value("user.type", repo) assert result is None # --------------------------------------------------------------------------- # 4. Stale [user] in config.toml is silently ignored # --------------------------------------------------------------------------- class TestUserSectionNotLoaded: def test_load_config_ignores_user_section(self, tmp_path: pathlib.Path) -> None: """_load_config never loads [user] — it is not part of the config schema.""" from muse.cli.config import _load_config cp = config_toml_path(tmp_path) cp.parent.mkdir(parents=True) cp.write_text( '[user]\nhandle = "gabriel"\ntype = "agent"\n\n' '[hub]\nurl = "https://localhost:1337"\n', encoding="utf-8", ) config = _load_config(cp) assert "user" not in config assert config.get("hub", {}).get("url") == "https://localhost:1337" # --------------------------------------------------------------------------- # 5. Commit author from identity.toml # --------------------------------------------------------------------------- class TestCommitAuthorFromIdentity: def test_commit_uses_identity_handle_not_config( self, tmp_path: pathlib.Path ) -> None: """commit --author must fall back to identity.toml handle, not config [user].""" from muse.cli.config import get_config_value repo = _make_repo(tmp_path) # config.toml has a wrong/stale handle _write_config( repo, '[hub]\nurl = "https://localhost:1337"\n\n' '[user]\nhandle = "wrong-handle"\n', ) with patch("muse.core.identity._IDENTITY_FILE", tmp_path / "identity.toml"): (tmp_path / "identity.toml").write_text( '["localhost:1337"]\ntype = "human"\nhandle = "gabriel"\n' 'algorithm = "ed25519"\nfingerprint = "sha256:abc"\nhd_path = "m/0\'"\n', encoding="utf-8", ) handle = get_config_value("user.handle", repo) assert handle == "gabriel" assert handle != "wrong-handle"