cgcardona / muse public
test_hub_client.py python
202 lines 7.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for MuseHubClient — JWT auth injection and error handling.
2
3 Covers acceptance criteria:
4 - Token from config.toml is sent in Authorization header on every request.
5 - Missing/empty token causes exit 1 with an actionable message.
6 - The raw token value never appears in log output.
7
8 All tests are fully isolated: they use ``tmp_path`` to create
9 ``.muse/config.toml`` without touching the real filesystem, and
10 ``unittest.mock`` to avoid real HTTP requests.
11 """
12 from __future__ import annotations
13
14 import logging
15 import pathlib
16 from unittest.mock import AsyncMock, MagicMock, patch
17
18 import pytest
19 import typer
20
21 from maestro.muse_cli.hub_client import MuseHubClient, _MISSING_TOKEN_MSG
22 from maestro.muse_cli.errors import ExitCode
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29
30 def _write_config(muse_dir: pathlib.Path, token: str) -> None:
31 """Write a minimal .muse/config.toml with the given token."""
32 muse_dir.mkdir(parents=True, exist_ok=True)
33 (muse_dir / "config.toml").write_text(
34 f'[auth]\ntoken = "{token}"\n',
35 encoding="utf-8",
36 )
37
38
39 # ---------------------------------------------------------------------------
40 # test_hub_client_reads_token_from_config
41 # ---------------------------------------------------------------------------
42
43
44 @pytest.mark.anyio
45 async def test_hub_client_reads_token_from_config(tmp_path: pathlib.Path) -> None:
46 """Token from config.toml appears in Authorization header of every request.
47
48 The mock captures the headers passed to httpx.AsyncClient.__init__ so we
49 can assert without making a real network call.
50 """
51 _write_config(tmp_path / ".muse", "super-secret-token-abc123")
52
53 captured_headers: dict[str, str] = {}
54
55 mock_async_client = MagicMock()
56 mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client)
57 mock_async_client.__aexit__ = AsyncMock(return_value=None)
58 mock_async_client.aclose = AsyncMock()
59
60 def _fake_client_init(**kwargs: object) -> MagicMock:
61 raw = kwargs.get("headers", {})
62 if isinstance(raw, dict):
63 captured_headers.update(raw)
64 return mock_async_client
65
66 with patch(
67 "maestro.muse_cli.hub_client.httpx.AsyncClient",
68 side_effect=_fake_client_init,
69 ):
70 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
71 async with hub:
72 pass
73
74 assert "Authorization" in captured_headers
75 assert captured_headers["Authorization"] == "Bearer super-secret-token-abc123"
76
77
78 # ---------------------------------------------------------------------------
79 # test_hub_client_missing_token_exits_1
80 # ---------------------------------------------------------------------------
81
82
83 def test_hub_client_missing_token_exits_1(tmp_path: pathlib.Path) -> None:
84 """_build_auth_headers raises typer.Exit(1) when [auth] token is absent.
85
86 Creates a .muse dir but no config.toml, so get_auth_token returns None.
87 The client must print the instructive message and exit with code 1.
88 """
89 (tmp_path / ".muse").mkdir()
90
91 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
92
93 with pytest.raises(typer.Exit) as exc_info:
94 hub._build_auth_headers()
95
96 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
97
98
99 def test_hub_client_missing_token_message_is_instructive(
100 tmp_path: pathlib.Path,
101 capsys: pytest.CaptureFixture[str],
102 ) -> None:
103 """The error message tells the user exactly how to fix the problem."""
104 (tmp_path / ".muse").mkdir()
105
106 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
107
108 with pytest.raises(typer.Exit):
109 hub._build_auth_headers()
110
111 # typer.echo writes to stdout
112 captured = capsys.readouterr()
113 assert "No auth token configured" in captured.out
114 assert "config.toml" in captured.out
115
116
117 def test_hub_client_empty_token_exits_1(tmp_path: pathlib.Path) -> None:
118 """_build_auth_headers exits 1 when token is present but empty string."""
119 _write_config(tmp_path / ".muse", "")
120
121 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
122
123 with pytest.raises(typer.Exit) as exc_info:
124 hub._build_auth_headers()
125
126 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
127
128
129 # ---------------------------------------------------------------------------
130 # test_hub_client_token_not_logged
131 # ---------------------------------------------------------------------------
132
133
134 def test_hub_client_token_not_logged(
135 tmp_path: pathlib.Path,
136 caplog: pytest.LogCaptureFixture,
137 ) -> None:
138 """The raw token value never appears in any log record.
139
140 Uses caplog to capture all log records at DEBUG level and asserts that
141 the actual token string is absent from every message.
142 """
143 secret_token = "my-very-secret-jwt-token-xyz789"
144 _write_config(tmp_path / ".muse", secret_token)
145
146 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
147
148 with caplog.at_level(logging.DEBUG, logger="maestro.muse_cli.hub_client"):
149 hub._build_auth_headers()
150
151 for record in caplog.records:
152 assert secret_token not in record.getMessage(), (
153 f"Token value leaked into log record: {record.getMessage()!r}"
154 )
155
156 # Also assert the masked placeholder is used (positive signal)
157 log_text = "\n".join(r.getMessage() for r in caplog.records)
158 assert "Bearer ***" in log_text
159
160
161 # ---------------------------------------------------------------------------
162 # test_hub_client_requires_context_manager
163 # ---------------------------------------------------------------------------
164
165
166 @pytest.mark.anyio
167 async def test_hub_client_requires_context_manager(tmp_path: pathlib.Path) -> None:
168 """Calling .get() outside async context manager raises RuntimeError."""
169 _write_config(tmp_path / ".muse", "some-token")
170
171 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
172
173 with pytest.raises(RuntimeError, match="async context manager"):
174 await hub.get("/api/v1/musehub/repos/test")
175
176
177 # ---------------------------------------------------------------------------
178 # test_hub_client_closes_on_exit
179 # ---------------------------------------------------------------------------
180
181
182 @pytest.mark.anyio
183 async def test_hub_client_closes_http_session_on_exit(tmp_path: pathlib.Path) -> None:
184 """The underlying httpx.AsyncClient is closed on context manager exit."""
185 _write_config(tmp_path / ".muse", "close-test-token")
186
187 aclose_called = False
188
189 class _FakeAsyncClient:
190 def __init__(self, **_kwargs: object) -> None:
191 pass
192
193 async def aclose(self) -> None:
194 nonlocal aclose_called
195 aclose_called = True
196
197 with patch("maestro.muse_cli.hub_client.httpx.AsyncClient", _FakeAsyncClient):
198 hub = MuseHubClient(base_url="https://hub.example.com", repo_root=tmp_path)
199 async with hub:
200 pass
201
202 assert aclose_called, "httpx.AsyncClient.aclose() must be called on exit"