Skip to content

Commit 2f7f8c9

Browse files
committed
add tests
Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent bbf756c commit 2f7f8c9

3 files changed

Lines changed: 561 additions & 3 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def anyio_backend():
6+
return "asyncio"
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
import logging
2+
from unittest.mock import AsyncMock, Mock, patch
3+
4+
import pytest
5+
6+
from jumpstarter_cli.shell import (
7+
_attempt_token_recovery,
8+
_monitor_token_expiry,
9+
_try_refresh_token,
10+
_try_reload_token_from_disk,
11+
_update_lease_channel,
12+
_warn_refresh_failed,
13+
)
14+
15+
pytestmark = pytest.mark.anyio
16+
17+
18+
def _make_config(token="tok", refresh_token="rt", path="/tmp/config.yaml"):
19+
"""Create a mock config with sensible defaults."""
20+
config = Mock()
21+
config.token = token
22+
config.refresh_token = refresh_token
23+
config.path = path
24+
config.channel = AsyncMock(return_value=Mock(name="new_channel"))
25+
return config
26+
27+
28+
def _make_lease():
29+
"""Create a mock lease with a refresh_channel method."""
30+
lease = Mock()
31+
lease.refresh_channel = Mock()
32+
return lease
33+
34+
35+
class TestUpdateLeaseChannel:
36+
async def test_updates_channel_on_lease(self):
37+
config = _make_config()
38+
lease = _make_lease()
39+
40+
await _update_lease_channel(config, lease)
41+
42+
config.channel.assert_awaited_once()
43+
lease.refresh_channel.assert_called_once_with(config.channel.return_value)
44+
45+
async def test_noop_when_lease_is_none(self):
46+
config = _make_config()
47+
48+
await _update_lease_channel(config, None)
49+
50+
config.channel.assert_not_awaited()
51+
52+
53+
class TestTryRefreshToken:
54+
async def test_returns_false_when_no_refresh_token(self):
55+
config = _make_config(refresh_token=None)
56+
assert await _try_refresh_token(config, _make_lease()) is False
57+
58+
async def test_returns_false_when_refresh_token_is_empty(self):
59+
config = _make_config(refresh_token="")
60+
assert await _try_refresh_token(config, _make_lease()) is False
61+
62+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
63+
@patch("jumpstarter_cli.shell.Config")
64+
@patch("jumpstarter_cli.shell.decode_jwt_issuer", return_value="https://issuer")
65+
async def test_successful_refresh(self, _mock_issuer, mock_oidc_cls, mock_save):
66+
config = _make_config()
67+
lease = _make_lease()
68+
69+
mock_oidc = AsyncMock()
70+
mock_oidc.refresh_token_grant.return_value = {
71+
"access_token": "new_tok",
72+
"refresh_token": "new_rt",
73+
}
74+
mock_oidc_cls.return_value = mock_oidc
75+
76+
result = await _try_refresh_token(config, lease)
77+
78+
assert result is True
79+
assert config.token == "new_tok"
80+
assert config.refresh_token == "new_rt"
81+
lease.refresh_channel.assert_called_once()
82+
mock_save.save.assert_called_once()
83+
84+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
85+
@patch("jumpstarter_cli.shell.Config")
86+
@patch("jumpstarter_cli.shell.decode_jwt_issuer", return_value="https://issuer")
87+
async def test_successful_refresh_without_new_refresh_token(
88+
self, _mock_issuer, mock_oidc_cls, _mock_save
89+
):
90+
config = _make_config()
91+
lease = _make_lease()
92+
93+
mock_oidc = AsyncMock()
94+
mock_oidc.refresh_token_grant.return_value = {
95+
"access_token": "new_tok",
96+
# No refresh_token in response
97+
}
98+
mock_oidc_cls.return_value = mock_oidc
99+
100+
result = await _try_refresh_token(config, lease)
101+
102+
assert result is True
103+
assert config.token == "new_tok"
104+
assert config.refresh_token == "rt" # unchanged
105+
106+
@patch("jumpstarter_cli.shell.decode_jwt_issuer", side_effect=ValueError("bad jwt"))
107+
async def test_rollback_on_failure(self, _mock_issuer):
108+
config = _make_config(token="original_tok", refresh_token="original_rt")
109+
lease = _make_lease()
110+
111+
result = await _try_refresh_token(config, lease)
112+
113+
assert result is False
114+
assert config.token == "original_tok"
115+
assert config.refresh_token == "original_rt"
116+
lease.refresh_channel.assert_not_called()
117+
118+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
119+
@patch("jumpstarter_cli.shell.Config")
120+
@patch("jumpstarter_cli.shell.decode_jwt_issuer", return_value="https://issuer")
121+
async def test_save_failure_does_not_fail_refresh(
122+
self, _mock_issuer, mock_oidc_cls, mock_save, caplog
123+
):
124+
"""Disk save is best-effort; refresh should still succeed."""
125+
config = _make_config()
126+
lease = _make_lease()
127+
128+
mock_oidc = AsyncMock()
129+
mock_oidc.refresh_token_grant.return_value = {
130+
"access_token": "new_tok",
131+
}
132+
mock_oidc_cls.return_value = mock_oidc
133+
mock_save.save.side_effect = OSError("disk full")
134+
135+
with caplog.at_level(logging.WARNING):
136+
result = await _try_refresh_token(config, lease)
137+
138+
assert result is True
139+
assert config.token == "new_tok"
140+
assert "Failed to save refreshed token to disk" in caplog.text
141+
142+
143+
class TestTryReloadTokenFromDisk:
144+
async def test_returns_false_when_no_path(self):
145+
config = _make_config(path=None)
146+
assert await _try_reload_token_from_disk(config, _make_lease()) is False
147+
148+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
149+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds", return_value=3600)
150+
async def test_successful_reload(self, _mock_remaining, mock_client_cfg):
151+
config = _make_config(token="old_tok", refresh_token="old_rt")
152+
lease = _make_lease()
153+
154+
disk_config = Mock()
155+
disk_config.token = "disk_tok"
156+
disk_config.refresh_token = "disk_rt"
157+
mock_client_cfg.from_file.return_value = disk_config
158+
159+
result = await _try_reload_token_from_disk(config, lease)
160+
161+
assert result is True
162+
assert config.token == "disk_tok"
163+
assert config.refresh_token == "disk_rt"
164+
lease.refresh_channel.assert_called_once()
165+
166+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
167+
async def test_returns_false_when_disk_token_is_same(self, mock_client_cfg):
168+
config = _make_config(token="same_tok")
169+
disk_config = Mock()
170+
disk_config.token = "same_tok"
171+
mock_client_cfg.from_file.return_value = disk_config
172+
173+
result = await _try_reload_token_from_disk(config, _make_lease())
174+
175+
assert result is False
176+
177+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
178+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds", return_value=-10)
179+
async def test_returns_false_when_disk_token_is_expired(
180+
self, _mock_remaining, mock_client_cfg
181+
):
182+
config = _make_config(token="old_tok")
183+
disk_config = Mock()
184+
disk_config.token = "disk_tok"
185+
mock_client_cfg.from_file.return_value = disk_config
186+
187+
result = await _try_reload_token_from_disk(config, _make_lease())
188+
189+
assert result is False
190+
assert config.token == "old_tok" # unchanged
191+
192+
@patch("jumpstarter_cli.shell.ClientConfigV1Alpha1")
193+
async def test_rollback_on_file_error(self, mock_client_cfg):
194+
config = _make_config(token="orig_tok", refresh_token="orig_rt")
195+
mock_client_cfg.from_file.side_effect = FileNotFoundError("gone")
196+
197+
result = await _try_reload_token_from_disk(config, _make_lease())
198+
199+
assert result is False
200+
assert config.token == "orig_tok"
201+
assert config.refresh_token == "orig_rt"
202+
203+
204+
class TestAttemptTokenRecovery:
205+
@patch("jumpstarter_cli.shell._try_reload_token_from_disk", new_callable=AsyncMock)
206+
@patch("jumpstarter_cli.shell._try_refresh_token", new_callable=AsyncMock)
207+
async def test_returns_message_on_oidc_success(self, mock_refresh, mock_disk):
208+
mock_refresh.return_value = True
209+
210+
result = await _attempt_token_recovery(Mock(), Mock(), 60)
211+
212+
assert result == "Token refreshed automatically."
213+
mock_disk.assert_not_awaited() # should not fall through
214+
215+
@patch("jumpstarter_cli.shell._try_reload_token_from_disk", new_callable=AsyncMock)
216+
@patch("jumpstarter_cli.shell._try_refresh_token", new_callable=AsyncMock)
217+
async def test_falls_back_to_disk_reload(self, mock_refresh, mock_disk):
218+
mock_refresh.return_value = False
219+
mock_disk.return_value = True
220+
221+
result = await _attempt_token_recovery(Mock(), Mock(), 60)
222+
223+
assert result == "Token reloaded from login."
224+
225+
@patch("jumpstarter_cli.shell._try_reload_token_from_disk", new_callable=AsyncMock)
226+
@patch("jumpstarter_cli.shell._try_refresh_token", new_callable=AsyncMock)
227+
async def test_returns_none_when_all_fail(self, mock_refresh, mock_disk):
228+
mock_refresh.return_value = False
229+
mock_disk.return_value = False
230+
231+
result = await _attempt_token_recovery(Mock(), Mock(), 60)
232+
233+
assert result is None
234+
235+
236+
class TestWarnRefreshFailed:
237+
@patch("jumpstarter_cli.shell.click")
238+
def test_warns_yellow_when_time_remaining(self, mock_click):
239+
_warn_refresh_failed(300)
240+
mock_click.style.assert_called_once()
241+
_, kwargs = mock_click.style.call_args
242+
assert kwargs["fg"] == "yellow"
243+
244+
@patch("jumpstarter_cli.shell.click")
245+
def test_warns_red_when_expired(self, mock_click):
246+
_warn_refresh_failed(-10)
247+
mock_click.style.assert_called_once()
248+
_, kwargs = mock_click.style.call_args
249+
assert kwargs["fg"] == "red"
250+
251+
252+
class TestMonitorTokenExpiry:
253+
async def test_returns_immediately_when_no_token(self):
254+
config = Mock(spec=[]) # no token attribute
255+
cancel_scope = Mock(cancel_called=False)
256+
257+
await _monitor_token_expiry(config, None, cancel_scope)
258+
# Should return without error
259+
260+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
261+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds", return_value=None)
262+
async def test_returns_when_remaining_is_none(self, _mock_remaining, _mock_sleep):
263+
config = _make_config()
264+
cancel_scope = Mock(cancel_called=False)
265+
266+
await _monitor_token_expiry(config, None, cancel_scope)
267+
268+
@patch("jumpstarter_cli.shell.click")
269+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
270+
@patch("jumpstarter_cli.shell._attempt_token_recovery", new_callable=AsyncMock)
271+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds")
272+
async def test_refreshes_when_below_threshold(
273+
self, mock_remaining, mock_recovery, mock_sleep, mock_click
274+
):
275+
# First call: below threshold; second call: raise to exit
276+
mock_remaining.side_effect = [60, Exception("done")]
277+
mock_recovery.return_value = "Token refreshed automatically."
278+
config = _make_config()
279+
cancel_scope = Mock(cancel_called=False)
280+
281+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
282+
283+
mock_recovery.assert_awaited_once()
284+
# Should print the green success message
285+
mock_click.echo.assert_called()
286+
287+
@patch("jumpstarter_cli.shell.click")
288+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
289+
@patch("jumpstarter_cli.shell._attempt_token_recovery", new_callable=AsyncMock)
290+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds")
291+
async def test_warns_when_refresh_fails(
292+
self, mock_remaining, mock_recovery, mock_sleep, mock_click
293+
):
294+
mock_remaining.side_effect = [60, Exception("done")]
295+
mock_recovery.return_value = None # all recovery failed
296+
config = _make_config()
297+
cancel_scope = Mock(cancel_called=False)
298+
299+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
300+
301+
mock_recovery.assert_awaited_once()
302+
303+
@patch("jumpstarter_cli.shell.click")
304+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
305+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds")
306+
async def test_warns_within_expiry_window(
307+
self, mock_remaining, mock_sleep, mock_click
308+
):
309+
from jumpstarter_cli_common.oidc import TOKEN_EXPIRY_WARNING_SECONDS
310+
311+
# First iteration: within warning window but above refresh threshold
312+
# Second iteration: exit via exception
313+
mock_remaining.side_effect = [
314+
TOKEN_EXPIRY_WARNING_SECONDS - 10,
315+
Exception("done"),
316+
]
317+
config = _make_config()
318+
cancel_scope = Mock(cancel_called=False)
319+
320+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
321+
322+
# Verify warning was echoed
323+
mock_click.echo.assert_called()
324+
args = mock_click.style.call_args
325+
assert "auto-refresh" in args[0][0]
326+
327+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
328+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds", return_value=500)
329+
async def test_sleeps_30s_when_above_threshold(self, _mock_remaining, mock_sleep):
330+
# Exit after one loop via cancel_called
331+
call_count = 0
332+
333+
def check_cancelled():
334+
nonlocal call_count
335+
call_count += 1
336+
return call_count > 1
337+
338+
config = _make_config()
339+
cancel_scope = Mock()
340+
type(cancel_scope).cancel_called = property(lambda self: check_cancelled())
341+
342+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
343+
344+
mock_sleep.assert_awaited_with(30)
345+
346+
@patch("jumpstarter_cli.shell.click")
347+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
348+
@patch("jumpstarter_cli.shell._attempt_token_recovery", new_callable=AsyncMock)
349+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds")
350+
async def test_sleeps_5s_when_below_threshold(
351+
self, mock_remaining, mock_recovery, mock_sleep, _mock_click
352+
):
353+
mock_remaining.side_effect = [60, Exception("done")]
354+
mock_recovery.return_value = None
355+
config = _make_config()
356+
cancel_scope = Mock(cancel_called=False)
357+
358+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
359+
360+
mock_sleep.assert_awaited_with(5)
361+
362+
@patch("jumpstarter_cli.shell.click")
363+
@patch("jumpstarter_cli.shell.anyio.sleep", new_callable=AsyncMock)
364+
@patch("jumpstarter_cli.shell._attempt_token_recovery", new_callable=AsyncMock)
365+
@patch("jumpstarter_cli.shell.get_token_remaining_seconds")
366+
async def test_does_not_cancel_scope_on_expiry(
367+
self, mock_remaining, mock_recovery, mock_sleep, _mock_click
368+
):
369+
"""The monitor must never cancel the scope — the shell stays alive."""
370+
mock_remaining.side_effect = [60, Exception("done")]
371+
mock_recovery.return_value = None
372+
config = _make_config()
373+
cancel_scope = Mock(cancel_called=False)
374+
375+
await _monitor_token_expiry(config, _make_lease(), cancel_scope)
376+
377+
cancel_scope.cancel.assert_not_called()

0 commit comments

Comments
 (0)