elva-cli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. elva_cli-0.5.0/CHANGELOG.md +32 -0
  2. {elva_cli-0.4.0 → elva_cli-0.5.0}/PKG-INFO +1 -1
  3. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/_version.py +2 -2
  4. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/__init__.py +2 -0
  5. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/session.py +110 -17
  6. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/http.py +45 -4
  7. elva_cli-0.5.0/src/elva_cli/core/api/identity.py +21 -0
  8. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/targets.py +23 -5
  9. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth.py +2 -1
  10. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/import_spec.py +18 -4
  11. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/whoami.py +11 -19
  12. elva_cli-0.5.0/tests/unit/test_refresh_client.py +273 -0
  13. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_targets.py +1 -1
  14. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_whoami_flow.py +11 -11
  15. {elva_cli-0.4.0 → elva_cli-0.5.0}/.gitignore +0 -0
  16. {elva_cli-0.4.0 → elva_cli-0.5.0}/README.md +0 -0
  17. {elva_cli-0.4.0 → elva_cli-0.5.0}/pyproject.toml +0 -0
  18. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/__init__.py +0 -0
  19. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/__main__.py +0 -0
  20. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/models.py +0 -0
  21. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/store.py +0 -0
  22. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/__init__.py +0 -0
  23. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/auth.py +0 -0
  24. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/config.py +0 -0
  25. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/import_.py +0 -0
  26. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/whoami.py +0 -0
  27. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/context.py +0 -0
  28. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/__init__.py +0 -0
  29. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/__init__.py +0 -0
  30. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/__init__.py +0 -0
  31. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth_result.py +0 -0
  32. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/config.py +0 -0
  33. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/import_result.py +0 -0
  34. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/whoami_result.py +0 -0
  35. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/__init__.py +0 -0
  36. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/detect.py +0 -0
  37. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/errors.py +0 -0
  38. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/logging.py +0 -0
  39. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/main.py +0 -0
  40. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/registry.py +0 -0
  41. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/__init__.py +0 -0
  42. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/loader.py +0 -0
  43. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/models.py +0 -0
  44. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/paths.py +0 -0
  45. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/telemetry.py +0 -0
  46. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/__init__.py +0 -0
  47. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/console.py +0 -0
  48. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/output.py +0 -0
  49. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/prompts.py +0 -0
  50. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/__init__.py +0 -0
  51. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/auth.py +0 -0
  52. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/base.py +0 -0
  53. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/config.py +0 -0
  54. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/import_spec.py +0 -0
  55. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/whoami.py +0 -0
  56. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/theme.py +0 -0
  57. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/views/__init__.py +0 -0
  58. {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/update.py +0 -0
  59. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/conftest.py +0 -0
  60. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_auth_logout_command.py +0 -0
  61. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_cli_exit_codes.py +0 -0
  62. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_config_command.py +0 -0
  63. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_import_command.py +0 -0
  64. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_lazy_imports.py +0 -0
  65. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_never_blocks.py +0 -0
  66. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_output_streams.py +0 -0
  67. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_whoami_command.py +0 -0
  68. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_boundary.py +0 -0
  69. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_context.py +0 -0
  70. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_credentials.py +0 -0
  71. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_error_boundary.py +0 -0
  72. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_errors.py +0 -0
  73. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_exit_codes.py +0 -0
  74. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_http.py +0 -0
  75. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_import_flow.py +0 -0
  76. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_interactivity.py +0 -0
  77. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_login_flow.py +0 -0
  78. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_logout_flow.py +0 -0
  79. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_output.py +0 -0
  80. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_prompts.py +0 -0
  81. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_session.py +0 -0
  82. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_settings_loader.py +0 -0
  83. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_spec_detect.py +0 -0
  84. {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_token_store.py +0 -0
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Added
6
+ - **Identify as a first-class refresh client (ELVA-200).** Every request now
7
+ sends `X-Elva-Client: cli` and a `User-Agent: elva-cli/<version>`. The refresh
8
+ and CLI-login exchanges carry the marker so the backend routes them on the
9
+ body-token path and, once its compatibility flag is turned off, does not
10
+ reject the CLI as an unmarked client.
11
+ - **Refresh-and-retry on 401.** An idempotent request (GET/HEAD) that returns
12
+ 401 on a token that looked valid now forces one refresh and retries once.
13
+ Non-idempotent requests (the spec upload) are never auto-replayed.
14
+ - **Backoff on transient refresh failures.** A refresh that hits 429/5xx or a
15
+ network error is retried with backoff (1s/2s/4s) before surfacing as a
16
+ transient `ApiError` (exit 5); it never forces a re-login.
17
+
18
+ ### Changed
19
+ - Refresh continues to happen proactively ~60s before the access token expires
20
+ and to persist the rotated token atomically (temp file + rename, 0600) before
21
+ the response is used — unchanged, but now covered by explicit tests.
22
+
23
+ ### Compatibility
24
+ This version works with **both** the flagged and the unflagged backend:
25
+ - While the backend keeps `AUTH_ALLOW_BODY_REFRESH_TOKEN` on, unmarked body
26
+ refreshes still work, so **older CLI versions keep working** too.
27
+ - Once the backend turns that flag off (this CLI version being the minimum
28
+ supported), the server rejects unmarked and legacy ("family-less") refresh
29
+ tokens with `401 REFRESH_TOKEN_LEGACY`. This CLI maps that to
30
+ *"Your session has expired. Run `elva auth login` to sign in again."*
31
+ (exit code 3). **Older CLI versions stop working at that point** and users
32
+ must upgrade and re-login once.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: elva-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Elva - CLI for Theneo Elva
5
5
  Project-URL: Homepage, https://getelva.ai
6
6
  Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
23
23
  forget_stored_credentials,
24
24
  get_access_token,
25
25
  logout,
26
+ refresh_now,
26
27
  save_login,
27
28
  save_pat,
28
29
  )
@@ -34,6 +35,7 @@ __all__ = [
34
35
  "forget_stored_credentials",
35
36
  "get_access_token",
36
37
  "logout",
38
+ "refresh_now",
37
39
  "save_login",
38
40
  "save_pat",
39
41
  ]
@@ -20,18 +20,20 @@ import contextlib
20
20
  import json
21
21
  import logging
22
22
  import os
23
+ import time
23
24
  from datetime import UTC, datetime, timedelta
24
25
  from typing import TYPE_CHECKING, Any
25
26
 
26
27
  from elva_cli.auth.models import Credentials
27
28
  from elva_cli.auth.store import FileStore, KeyringStore, StoreUnavailableError, TokenStore
29
+ from elva_cli.core.api.identity import client_headers
28
30
  from elva_cli.core.services.auth_result import LogoutResult as LogoutResult
29
31
  from elva_cli.core.services.auth_result import LogoutStatus as LogoutStatus
30
32
  from elva_cli.errors import ApiError, AuthError
31
33
  from elva_cli.settings import paths
32
34
 
33
35
  if TYPE_CHECKING:
34
- from collections.abc import Iterator
36
+ from collections.abc import Callable, Iterator
35
37
 
36
38
  _logger = logging.getLogger(__name__)
37
39
 
@@ -41,6 +43,16 @@ _LOCK_FILE = "refresh.lock"
41
43
  _HTTP_TIMEOUT = 10
42
44
  _LOGOUT_TIMEOUT = 5
43
45
 
46
+ # Backoff for a transiently-failing refresh (429 / 5xx / network): the initial
47
+ # attempt plus retries sleeping these seconds between them, then give up with an
48
+ # ApiError (transient, exit 5) - never a forced re-login.
49
+ _REFRESH_BACKOFF_SECONDS = (1.0, 2.0, 4.0)
50
+
51
+ # Distinct backend code (ELVA-200) meaning the refresh token predates the
52
+ # current format and the user must sign in again - purely informational here,
53
+ # since every 401/403 already routes to a re-login.
54
+ _LEGACY_REFRESH_CODE = "REFRESH_TOKEN_LEGACY"
55
+
44
56
 
45
57
  class RefreshFailedError(Exception):
46
58
  """The backend positively rejected this refresh token (401/403) — the
@@ -151,26 +163,70 @@ def _refresh_lock() -> Iterator[None]:
151
163
  os.close(fd)
152
164
 
153
165
 
154
- def _refresh(refresh_token: str, *, base_url: str, timeout: float = _HTTP_TIMEOUT) -> Credentials:
166
+ def _refresh(
167
+ refresh_token: str,
168
+ *,
169
+ base_url: str,
170
+ timeout: float = _HTTP_TIMEOUT,
171
+ sleep: Callable[[float], None] = time.sleep,
172
+ ) -> Credentials:
173
+ """Spend the refresh token for a fresh pair.
174
+
175
+ Sends X-Elva-Client: cli so the backend answers on the body path and, once
176
+ its compat flag is off, doesn't reject us as an unmarked client. A 401/403
177
+ is terminal (RefreshFailedError -> re-login); 429/5xx and network failures
178
+ are transient and retried with backoff before surfacing as ApiError."""
155
179
  import urllib.error
156
180
  import urllib.request
157
181
 
158
- request = urllib.request.Request(
159
- f"{base_url}/api/auth/refresh-tokens",
160
- data=json.dumps({"refreshToken": refresh_token}).encode("utf-8"),
161
- headers={"Content-Type": "application/json"},
162
- method="POST",
163
- )
182
+ last_transient: Exception | None = None
183
+ # Initial attempt (delay 0), then one retry per backoff delay.
184
+ for delay in (0.0, *_REFRESH_BACKOFF_SECONDS):
185
+ if delay:
186
+ sleep(delay)
187
+ request = urllib.request.Request(
188
+ f"{base_url}/api/auth/refresh-tokens",
189
+ data=json.dumps({"refreshToken": refresh_token}).encode("utf-8"),
190
+ headers={"Content-Type": "application/json", **client_headers()},
191
+ method="POST",
192
+ )
193
+ try:
194
+ with urllib.request.urlopen(request, timeout=timeout) as response:
195
+ raw = response.read()
196
+ except urllib.error.HTTPError as exc:
197
+ if exc.code in (401, 403):
198
+ # Terminal: the token is dead (expired, rotated away, revoked,
199
+ # or legacy). Note the backend code when it says so, then
200
+ # re-login. Not retried.
201
+ code = _error_code(exc)
202
+ detail = " (legacy token)" if code == _LEGACY_REFRESH_CODE else ""
203
+ raise RefreshFailedError(
204
+ f"backend rejected refresh token (HTTP {exc.code}){detail}"
205
+ ) from exc
206
+ if exc.code == 429 or exc.code >= 500:
207
+ last_transient = exc
208
+ continue # transient - back off and retry
209
+ raise ApiError(f"Refreshing your session failed (HTTP {exc.code}).") from exc
210
+ except (urllib.error.URLError, TimeoutError) as exc:
211
+ last_transient = exc
212
+ continue # transient - back off and retry
213
+ else:
214
+ return _parse_refresh_body(raw)
215
+
216
+ raise ApiError("Could not reach the server to refresh your session.") from last_transient
217
+
218
+
219
+ def _error_code(exc: Any) -> str | None:
220
+ """The backend's machine-readable error code (`data`), if it sent one."""
164
221
  try:
165
- with urllib.request.urlopen(request, timeout=timeout) as response:
166
- raw = response.read()
167
- except urllib.error.HTTPError as exc:
168
- if exc.code in (401, 403):
169
- raise RefreshFailedError(f"backend rejected refresh token (HTTP {exc.code})") from exc
170
- raise ApiError(f"Refreshing your session failed (HTTP {exc.code}).") from exc
171
- except (urllib.error.URLError, TimeoutError) as exc:
172
- raise ApiError("Could not reach the server to refresh your session.") from exc
222
+ payload = json.loads(exc.read())
223
+ except (OSError, ValueError):
224
+ return None
225
+ code = payload.get("data") if isinstance(payload, dict) else None
226
+ return code if isinstance(code, str) else None
173
227
 
228
+
229
+ def _parse_refresh_body(raw: bytes) -> Credentials:
174
230
  unexpected = "The server returned an unexpected response while refreshing your session."
175
231
  try:
176
232
  body = json.loads(raw)
@@ -259,6 +315,43 @@ def _refresh_session(*, base_url: str) -> str:
259
315
  return refreshed.access_token
260
316
 
261
317
 
318
+ def refresh_now(*, base_url: str, stale_access_token: str) -> str:
319
+ """Force a refresh after a request 401'd on a token that looked valid by
320
+ expiry (the server rejected it early - password change, inactivity, etc.).
321
+
322
+ Bounded to sessions. Under the cross-process lock we re-read the store: if
323
+ another process already rotated (the stored access token differs from the
324
+ one that 401'd), return that instead of spending a second refresh - the
325
+ double-spend is exactly what would trip the backend's reuse detection.
326
+ Raises AuthError if there's no usable session to refresh."""
327
+ with _refresh_lock():
328
+ creds, _ = _load_from_first_available_store()
329
+ if (
330
+ creds is None
331
+ or creds.kind != "session"
332
+ or creds.refresh_token is None
333
+ or creds.refresh_expires_at is None
334
+ ):
335
+ raise AuthError("Your session has expired.")
336
+
337
+ if creds.access_token != stale_access_token:
338
+ # A sibling process refreshed while we held the stale token.
339
+ return creds.access_token
340
+
341
+ if creds.refresh_expires_at <= datetime.now(UTC):
342
+ _clear_all_stores()
343
+ raise AuthError("Your session has expired.")
344
+
345
+ try:
346
+ refreshed = _refresh(creds.refresh_token, base_url=base_url)
347
+ except RefreshFailedError as exc:
348
+ _clear_all_stores()
349
+ raise AuthError("Your session has expired.") from exc
350
+
351
+ _persist_refreshed(refreshed)
352
+ return refreshed.access_token
353
+
354
+
262
355
  def current_identity() -> str:
263
356
  """Where get_access_token(base_url=...) would source its token right now:
264
357
  'env' (ELVA_TOKEN), 'pat' (a stored personal access token), 'session' (a
@@ -299,7 +392,7 @@ def _revoke_server_side(
299
392
  request = urllib.request.Request(
300
393
  f"{base_url}/api/auth/logout",
301
394
  data=b"",
302
- headers={"Authorization": f"Bearer {access_token}"},
395
+ headers={"Authorization": f"Bearer {access_token}", **client_headers()},
303
396
  method="POST",
304
397
  )
305
398
  try:
@@ -13,11 +13,17 @@ from __future__ import annotations
13
13
  import json
14
14
  from typing import TYPE_CHECKING, Any
15
15
 
16
+ from elva_cli.core.api.identity import client_headers
16
17
  from elva_cli.errors import ApiError, AuthError, ElvaError
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  import urllib.error
20
- from collections.abc import Mapping
21
+ from collections.abc import Callable, Mapping
22
+
23
+ # Only these methods are safe to auto-retry after a refresh: they're idempotent
24
+ # and carry no body the server may have already acted on. A POST/PATCH that
25
+ # reached the server must not be silently replayed (ELVA-200 CLI review).
26
+ _RETRIABLE_METHODS = frozenset({"GET", "HEAD"})
21
27
 
22
28
  DEFAULT_TIMEOUT = 30.0
23
29
  UNEXPECTED_RESPONSE = "The server returned an unexpected response."
@@ -41,8 +47,14 @@ def default_error(error: HttpError, *, action: str) -> ElvaError:
41
47
  return ApiError(f"{action} failed (HTTP {error.status}).")
42
48
 
43
49
 
44
- def get_json(url: str, *, token: str, timeout: float = DEFAULT_TIMEOUT) -> Any:
45
- return _send(url, token=token, method="GET", timeout=timeout)
50
+ def get_json(
51
+ url: str,
52
+ *,
53
+ token: str,
54
+ timeout: float = DEFAULT_TIMEOUT,
55
+ reauth: Callable[[str], str] | None = None,
56
+ ) -> Any:
57
+ return _send(url, token=token, method="GET", timeout=timeout, reauth=reauth)
46
58
 
47
59
 
48
60
  def send_json(
@@ -52,6 +64,7 @@ def send_json(
52
64
  method: str,
53
65
  payload: Mapping[str, Any],
54
66
  timeout: float = DEFAULT_TIMEOUT,
67
+ reauth: Callable[[str], str] | None = None,
55
68
  ) -> Any:
56
69
  return _send(
57
70
  url,
@@ -60,6 +73,7 @@ def send_json(
60
73
  body=json.dumps(payload).encode("utf-8"),
61
74
  content_type="application/json",
62
75
  timeout=timeout,
76
+ reauth=reauth,
63
77
  )
64
78
 
65
79
 
@@ -167,11 +181,38 @@ def _send(
167
181
  body: bytes | None = None,
168
182
  content_type: str | None = None,
169
183
  timeout: float,
184
+ reauth: Callable[[str], str] | None = None,
185
+ ) -> Any:
186
+ try:
187
+ return _attempt(
188
+ url, token=token, method=method, body=body, content_type=content_type, timeout=timeout
189
+ )
190
+ except HttpError as exc:
191
+ # A 401 on a token that looked valid means the server rejected it
192
+ # (e.g. it expired mid-command, or a sibling process rotated it).
193
+ # Refresh once and retry - but only for idempotent methods, so a
194
+ # POST/PATCH that already reached the server is never replayed.
195
+ if exc.status != 401 or reauth is None or method.upper() not in _RETRIABLE_METHODS:
196
+ raise
197
+ fresh = reauth(token)
198
+ return _attempt(
199
+ url, token=fresh, method=method, body=body, content_type=content_type, timeout=timeout
200
+ )
201
+
202
+
203
+ def _attempt(
204
+ url: str,
205
+ *,
206
+ token: str,
207
+ method: str,
208
+ body: bytes | None,
209
+ content_type: str | None,
210
+ timeout: float,
170
211
  ) -> Any:
171
212
  import urllib.error
172
213
  import urllib.request
173
214
 
174
- headers = {"Authorization": f"Bearer {token}"}
215
+ headers = {"Authorization": f"Bearer {token}", **client_headers()}
175
216
  if content_type is not None:
176
217
  headers["Content-Type"] = content_type
177
218
 
@@ -0,0 +1,21 @@
1
+ """How the CLI identifies itself to the Elva backend.
2
+
3
+ The `X-Elva-Client: cli` marker tells the refresh endpoint this is a
4
+ non-browser client that takes its refresh token in the response body (ELVA-200).
5
+ While the backend's AUTH_ALLOW_BODY_REFRESH_TOKEN flag is on it also accepts
6
+ unmarked body tokens; once that flag is flipped off (this CLI version being the
7
+ floor), the marker is required and unmarked/legacy tokens are rejected 401.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from elva_cli import __version__
13
+
14
+ CLIENT_HEADER = "X-Elva-Client"
15
+ CLIENT_NAME = "cli"
16
+ USER_AGENT = f"elva-cli/{__version__}"
17
+
18
+
19
+ def client_headers() -> dict[str, str]:
20
+ """Headers every Elva request should carry to identify this client."""
21
+ return {CLIENT_HEADER: CLIENT_NAME, "User-Agent": USER_AGENT}
@@ -10,11 +10,14 @@ from __future__ import annotations
10
10
 
11
11
  import re
12
12
  from dataclasses import dataclass
13
- from typing import Any
13
+ from typing import TYPE_CHECKING, Any
14
14
 
15
15
  from elva_cli.core.api.http import HttpError, default_error, get_json
16
16
  from elva_cli.errors import ApiError, UsageError
17
17
 
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
18
21
  _OBJECT_ID = re.compile(r"^[0-9a-fA-F]{24}$")
19
22
 
20
23
 
@@ -24,7 +27,13 @@ class Target:
24
27
  name: str
25
28
 
26
29
 
27
- def resolve_workspace(*, base_url: str, token: str, workspace: str | None) -> Target:
30
+ def resolve_workspace(
31
+ *,
32
+ base_url: str,
33
+ token: str,
34
+ workspace: str | None,
35
+ reauth: Callable[[str], str] | None = None,
36
+ ) -> Target:
28
37
  """The workspace to act in.
29
38
 
30
39
  With nothing given, a single-workspace account resolves to that one -- the
@@ -35,7 +44,7 @@ def resolve_workspace(*, base_url: str, token: str, workspace: str | None) -> Ta
35
44
  return Target(id=workspace, name=workspace)
36
45
 
37
46
  try:
38
- payload = get_json(f"{base_url}/api/companies/workspaces", token=token)
47
+ payload = get_json(f"{base_url}/api/companies/workspaces", token=token, reauth=reauth)
39
48
  except HttpError as exc:
40
49
  raise default_error(exc, action="Looking up your workspaces") from exc
41
50
 
@@ -73,13 +82,22 @@ def resolve_workspace(*, base_url: str, token: str, workspace: str | None) -> Ta
73
82
  return _exactly_one(matches, kind="workspace", wanted=workspace, available=found)
74
83
 
75
84
 
76
- def resolve_collection(*, base_url: str, token: str, company_id: str, collection: str) -> Target:
85
+ def resolve_collection(
86
+ *,
87
+ base_url: str,
88
+ token: str,
89
+ company_id: str,
90
+ collection: str,
91
+ reauth: Callable[[str], str] | None = None,
92
+ ) -> Target:
77
93
  """The collection to act on, by name or by id."""
78
94
  if _OBJECT_ID.match(collection):
79
95
  return Target(id=collection, name=collection)
80
96
 
81
97
  try:
82
- payload = get_json(f"{base_url}/api/companies/{company_id}/collections", token=token)
98
+ payload = get_json(
99
+ f"{base_url}/api/companies/{company_id}/collections", token=token, reauth=reauth
100
+ )
83
101
  except HttpError as exc:
84
102
  if exc.status == 404:
85
103
  raise UsageError(
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
20
20
  from elva_cli.auth import logout as logout_credentials
21
21
  from elva_cli.auth import save_login
22
22
  from elva_cli.auth.store import StoreUnavailableError
23
+ from elva_cli.core.api.identity import client_headers
23
24
  from elva_cli.core.services.auth_result import LoginResult, LogoutResult
24
25
  from elva_cli.errors import ApiError, AuthError
25
26
 
@@ -174,7 +175,7 @@ def _exchange_token(base_url: str, code: str, verifier: str) -> dict[str, Any]:
174
175
  request = urllib.request.Request(
175
176
  f"{base_url}/api/auth/cli/token",
176
177
  data=json.dumps({"code": code, "code_verifier": verifier}).encode("utf-8"),
177
- headers={"Content-Type": "application/json"},
178
+ headers={"Content-Type": "application/json", **client_headers()},
178
179
  method="POST",
179
180
  )
180
181
  try:
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from typing import TYPE_CHECKING, Any, NamedTuple
10
10
 
11
- from elva_cli.auth import get_access_token
11
+ from elva_cli.auth import get_access_token, refresh_now
12
12
  from elva_cli.core.api.http import HttpError, default_error, get_json, send_form, send_json
13
13
  from elva_cli.core.api.targets import Target, resolve_collection, resolve_workspace
14
14
  from elva_cli.core.services.import_result import Action, DryRunResult, ImportSpecResult
@@ -74,7 +74,13 @@ def import_spec(
74
74
  )
75
75
 
76
76
  token = get_access_token(base_url=base_url)
77
- space = resolve_workspace(base_url=base_url, token=token, workspace=workspace)
77
+
78
+ # Idempotent lookups/polls refresh-and-retry once on a 401 (e.g. the token
79
+ # expiring during a large upload); the upload itself is never auto-replayed.
80
+ def reauth(stale: str) -> str:
81
+ return refresh_now(base_url=base_url, stale_access_token=stale)
82
+
83
+ space = resolve_workspace(base_url=base_url, token=token, workspace=workspace, reauth=reauth)
78
84
 
79
85
  if update:
80
86
  outcome = _update(
@@ -85,6 +91,7 @@ def import_spec(
85
91
  source=source,
86
92
  data=data,
87
93
  spec_url=spec_url,
94
+ reauth=reauth,
88
95
  )
89
96
  else:
90
97
  outcome = _create(
@@ -306,9 +313,14 @@ def _update(
306
313
  source: str,
307
314
  data: bytes | None,
308
315
  spec_url: str | None,
316
+ reauth: Callable[[str], str] | None = None,
309
317
  ) -> _Outcome:
310
318
  target = resolve_collection(
311
- base_url=base_url, token=token, company_id=company_id, collection=collection
319
+ base_url=base_url,
320
+ token=token,
321
+ company_id=company_id,
322
+ collection=collection,
323
+ reauth=reauth,
312
324
  )
313
325
  url = f"{base_url}/api/companies/{company_id}/collections/{target.id}"
314
326
  try:
@@ -342,6 +354,7 @@ def _update(
342
354
  token=token,
343
355
  company_id=company_id,
344
356
  collection_id=target.id,
357
+ reauth=reauth,
345
358
  )
346
359
  return _Outcome(target, doc, confirmed, mcps)
347
360
 
@@ -426,6 +439,7 @@ def _settled(
426
439
  token: str,
427
440
  company_id: str,
428
441
  collection_id: str,
442
+ reauth: Callable[[str], str] | None = None,
429
443
  ) -> tuple[dict[str, Any], bool, tuple[str, ...]]:
430
444
  """The collection once the server has caught up with the spec just sent,
431
445
  whether it was actually observed to catch up, and the MCP servers
@@ -448,7 +462,7 @@ def _settled(
448
462
  for delay in _SETTLE_DELAYS:
449
463
  time.sleep(delay)
450
464
  try:
451
- body = get_json(url, token=token)
465
+ body = get_json(url, token=token, reauth=reauth)
452
466
  except (ApiError, HttpError):
453
467
  return latest, False, mcps
454
468
  doc = _document(body)
@@ -7,11 +7,11 @@ something to optimize away.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import json
11
10
  from typing import Any
12
11
 
13
12
  from elva_cli import auth
14
13
  from elva_cli.auth import get_access_token
14
+ from elva_cli.core.api.http import HttpError, get_json
15
15
  from elva_cli.core.services.whoami_result import WhoamiResult
16
16
  from elva_cli.errors import ApiError, AuthError
17
17
 
@@ -53,28 +53,20 @@ def _rejected_token_error() -> AuthError:
53
53
 
54
54
 
55
55
  def _fetch_me(base_url: str, token: str) -> dict[str, Any]:
56
- import urllib.error
57
- import urllib.request
56
+ # GET is idempotent, so on a 401 the shared layer refreshes once and retries
57
+ # (and carries the X-Elva-Client marker). A refresh that itself fails raises
58
+ # AuthError/ApiError straight out, which whoami() handles.
59
+ def reauth(stale: str) -> str:
60
+ return auth.refresh_now(base_url=base_url, stale_access_token=stale)
58
61
 
59
- request = urllib.request.Request(
60
- f"{base_url}/api/auth/me",
61
- headers={"Authorization": f"Bearer {token}"},
62
- method="GET",
63
- )
62
+ url = f"{base_url}/api/auth/me"
64
63
  try:
65
- with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT) as response:
66
- raw = response.read()
67
- except urllib.error.HTTPError as exc:
68
- if exc.code == 401:
64
+ body = get_json(url, token=token, timeout=_HTTP_TIMEOUT, reauth=reauth)
65
+ except HttpError as exc:
66
+ if exc.status == 401:
69
67
  raise AuthError("Your credentials are no longer valid.") from exc
70
- raise ApiError(f"Could not verify who you are (HTTP {exc.code}).") from exc
71
- except (urllib.error.URLError, TimeoutError) as exc:
72
- raise ApiError("Could not reach the server.") from exc
68
+ raise ApiError(f"Could not verify who you are (HTTP {exc.status}).") from exc
73
69
 
74
- try:
75
- body = json.loads(raw)
76
- except json.JSONDecodeError as exc:
77
- raise ApiError(_UNEXPECTED_RESPONSE) from exc
78
70
  if not isinstance(body, dict) or "user" not in body:
79
71
  raise ApiError(_UNEXPECTED_RESPONSE)
80
72
  return body
@@ -0,0 +1,273 @@
1
+ """ELVA-200: the CLI as a first-class refresh client.
2
+
3
+ Covers the X-Elva-Client marker, proactive + forced refresh, the shared HTTP
4
+ layer's 401->refresh->retry (GET/HEAD only), transient backoff, the legacy
5
+ re-login mapping, and crash-safe token persistence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ import urllib.error
13
+ from datetime import UTC, datetime, timedelta
14
+ from email.message import Message
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import pytest
18
+
19
+ from elva_cli.auth import session
20
+ from elva_cli.auth.models import Credentials
21
+ from elva_cli.auth.session import RefreshFailedError
22
+ from elva_cli.core.api import http
23
+ from elva_cli.core.api.identity import CLIENT_HEADER
24
+ from elva_cli.errors import ApiError, AuthError, ExitCode
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+ BASE_URL = "https://api.getelva.ai"
30
+
31
+
32
+ def _tokens_body(access: str = "new-access", refresh: str = "new-refresh") -> bytes:
33
+ later = "2099-01-01T00:00:00Z"
34
+ return json.dumps(
35
+ {
36
+ "access": {"token": access, "expires": later},
37
+ "refresh": {"token": refresh, "expires": later},
38
+ }
39
+ ).encode()
40
+
41
+
42
+ class _Resp:
43
+ def __init__(self, raw: bytes) -> None:
44
+ self._raw = raw
45
+
46
+ def read(self) -> bytes:
47
+ return self._raw
48
+
49
+ def __enter__(self) -> _Resp:
50
+ return self
51
+
52
+ def __exit__(self, *_: object) -> None:
53
+ return None
54
+
55
+
56
+ def _http_error(status: int, body: bytes = b"") -> urllib.error.HTTPError:
57
+ return urllib.error.HTTPError(
58
+ f"{BASE_URL}/api/auth/refresh-tokens", status, "err", Message(), io.BytesIO(body)
59
+ )
60
+
61
+
62
+ class TestClientMarker:
63
+ def test_refresh_sends_the_cli_marker(self, monkeypatch: pytest.MonkeyPatch) -> None:
64
+ captured: dict[str, Any] = {}
65
+
66
+ def fake_urlopen(request: Any, *_a: object, **_k: object) -> _Resp:
67
+ captured["request"] = request
68
+ return _Resp(_tokens_body())
69
+
70
+ monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
71
+ session._refresh("refresh-1", base_url=BASE_URL)
72
+
73
+ request = captured["request"]
74
+ assert request.get_header(CLIENT_HEADER.capitalize()) == "cli"
75
+ assert request.get_header("User-agent", "").startswith("elva-cli/")
76
+
77
+ def test_every_shared_http_request_carries_the_marker(
78
+ self, monkeypatch: pytest.MonkeyPatch
79
+ ) -> None:
80
+ captured: dict[str, Any] = {}
81
+
82
+ class _Opener:
83
+ def open(self, request: Any, timeout: float | None = None) -> _Resp:
84
+ captured["request"] = request
85
+ return _Resp(b'{"ok": true}')
86
+
87
+ monkeypatch.setattr(http, "_opener", lambda: _Opener())
88
+ http.get_json(f"{BASE_URL}/api/auth/me", token="tok")
89
+
90
+ assert captured["request"].get_header(CLIENT_HEADER.capitalize()) == "cli"
91
+
92
+
93
+ class TestRefreshResilience:
94
+ def test_429_then_success_backs_off_and_recovers(self, monkeypatch: pytest.MonkeyPatch) -> None:
95
+ calls = {"n": 0}
96
+
97
+ def fake_urlopen(*_a: object, **_k: object) -> _Resp:
98
+ calls["n"] += 1
99
+ if calls["n"] < 3:
100
+ raise _http_error(429)
101
+ return _Resp(_tokens_body(access="recovered"))
102
+
103
+ slept: list[float] = []
104
+ monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
105
+ creds = session._refresh("refresh-1", base_url=BASE_URL, sleep=slept.append)
106
+
107
+ assert creds.access_token == "recovered"
108
+ assert calls["n"] == 3
109
+ assert slept == [1.0, 2.0] # backed off before the two retries
110
+
111
+ def test_persistent_5xx_gives_up_as_a_transient_api_error(
112
+ self, monkeypatch: pytest.MonkeyPatch
113
+ ) -> None:
114
+ def always_503(*_a: object, **_k: object) -> None:
115
+ raise _http_error(503)
116
+
117
+ monkeypatch.setattr("urllib.request.urlopen", always_503)
118
+ with pytest.raises(ApiError):
119
+ session._refresh("refresh-1", base_url=BASE_URL, sleep=lambda _s: None)
120
+
121
+ def test_legacy_token_is_a_terminal_refresh_failure(
122
+ self, monkeypatch: pytest.MonkeyPatch
123
+ ) -> None:
124
+ body = json.dumps({"code": 401, "data": "REFRESH_TOKEN_LEGACY"}).encode()
125
+
126
+ def legacy_401(*_a: object, **_k: object) -> None:
127
+ raise _http_error(401, body)
128
+
129
+ monkeypatch.setattr("urllib.request.urlopen", legacy_401)
130
+ with pytest.raises(RefreshFailedError) as caught:
131
+ session._refresh("refresh-1", base_url=BASE_URL)
132
+ assert "legacy" in str(caught.value)
133
+
134
+ def test_a_dead_session_maps_to_auth_error_exit_3(self) -> None:
135
+ # A RefreshFailedError becomes AuthError, whose exit code is AUTH (3),
136
+ # with the "run elva auth login" hint.
137
+ err = AuthError("Your session has expired.")
138
+ assert err.exit_code == ExitCode.AUTH
139
+ assert err.hint is not None and "elva auth login" in err.hint
140
+
141
+
142
+ class TestRefreshNow:
143
+ @staticmethod
144
+ def _install(monkeypatch: pytest.MonkeyPatch, creds: Credentials) -> None:
145
+ monkeypatch.setattr(session, "_load_from_first_available_store", lambda: (creds, object()))
146
+
147
+ def test_returns_a_siblings_token_without_spending_a_refresh(
148
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
149
+ ) -> None:
150
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
151
+ now = datetime.now(UTC)
152
+ stored = Credentials(
153
+ kind="session",
154
+ access_token="already-rotated-by-sibling",
155
+ access_expires_at=now + timedelta(minutes=10),
156
+ refresh_token="refresh-2",
157
+ refresh_expires_at=now + timedelta(days=1),
158
+ )
159
+ self._install(monkeypatch, stored)
160
+
161
+ def forbidden(*_a: object, **_k: object) -> Credentials:
162
+ raise AssertionError("must not spend a refresh when a sibling already rotated")
163
+
164
+ monkeypatch.setattr(session, "_refresh", forbidden)
165
+
166
+ token = session.refresh_now(base_url=BASE_URL, stale_access_token="the-stale-one")
167
+ assert token == "already-rotated-by-sibling"
168
+
169
+ def test_forces_a_refresh_when_the_stale_token_is_still_current(
170
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
171
+ ) -> None:
172
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
173
+ now = datetime.now(UTC)
174
+ stored = Credentials(
175
+ kind="session",
176
+ access_token="stale",
177
+ # Not near expiry, so the ordinary near-expiry path wouldn't refresh;
178
+ # a 401 forces it anyway.
179
+ access_expires_at=now + timedelta(minutes=10),
180
+ refresh_token="refresh-3",
181
+ refresh_expires_at=now + timedelta(days=1),
182
+ )
183
+ self._install(monkeypatch, stored)
184
+ monkeypatch.setattr(session, "_persist_refreshed", lambda _c: None)
185
+ monkeypatch.setattr(
186
+ session,
187
+ "_refresh",
188
+ lambda token, *, base_url: Credentials(
189
+ kind="session",
190
+ access_token="forced-new",
191
+ access_expires_at=now + timedelta(minutes=15),
192
+ refresh_token="refresh-4",
193
+ refresh_expires_at=now + timedelta(days=1),
194
+ ),
195
+ )
196
+ assert session.refresh_now(base_url=BASE_URL, stale_access_token="stale") == "forced-new"
197
+
198
+
199
+ class TestHttpRetry:
200
+ @staticmethod
201
+ def _opener(monkeypatch: pytest.MonkeyPatch, sequence: list[Any]) -> dict[str, list[str]]:
202
+ seen: dict[str, list[str]] = {"tokens": []}
203
+
204
+ class _Opener:
205
+ def open(self, request: Any, timeout: float | None = None) -> _Resp:
206
+ seen["tokens"].append(request.get_header("Authorization"))
207
+ outcome = sequence.pop(0)
208
+ if isinstance(outcome, Exception):
209
+ raise outcome
210
+ return _Resp(outcome)
211
+
212
+ monkeypatch.setattr(http, "_opener", lambda: _Opener())
213
+ return seen
214
+
215
+ def test_get_refreshes_and_retries_once_on_401(self, monkeypatch: pytest.MonkeyPatch) -> None:
216
+ seen = self._opener(
217
+ monkeypatch,
218
+ [_http_error(401), b'{"ok": true}'], # first 401, retry succeeds
219
+ )
220
+ refreshed: list[str] = []
221
+
222
+ def reauth(stale: str) -> str:
223
+ refreshed.append(stale)
224
+ return "fresh-token"
225
+
226
+ body = http.get_json(f"{BASE_URL}/api/auth/me", token="stale-token", reauth=reauth)
227
+
228
+ assert body == {"ok": True}
229
+ assert refreshed == ["stale-token"] # reauth was handed the stale token
230
+ assert seen["tokens"] == ["Bearer stale-token", "Bearer fresh-token"]
231
+
232
+ def test_post_is_not_retried_after_401(self, monkeypatch: pytest.MonkeyPatch) -> None:
233
+ self._opener(monkeypatch, [_http_error(401)])
234
+ called = {"reauth": False}
235
+
236
+ def reauth(_stale: str) -> str:
237
+ called["reauth"] = True
238
+ return "fresh"
239
+
240
+ with pytest.raises(http.HttpError):
241
+ http.send_json(
242
+ f"{BASE_URL}/api/x", token="tok", method="POST", payload={}, reauth=reauth
243
+ )
244
+ assert called["reauth"] is False # non-idempotent: never auto-replayed
245
+
246
+
247
+ class TestCrashSafePersist:
248
+ def test_a_failed_write_leaves_the_old_credentials_intact(
249
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
250
+ ) -> None:
251
+ monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
252
+ from elva_cli.auth.store import FileStore
253
+
254
+ store = FileStore()
255
+ old = Credentials.from_pat("old-token")
256
+ store.save(old)
257
+
258
+ # Simulate a crash after the temp file is written but before the rename.
259
+ import pathlib
260
+
261
+ def boom(self: pathlib.Path, target: Any) -> None:
262
+ raise OSError("crash before rename")
263
+
264
+ monkeypatch.setattr(pathlib.Path, "replace", boom)
265
+
266
+ new = Credentials.from_pat("new-token")
267
+ with pytest.raises(OSError, match="crash before rename"):
268
+ store.save(new)
269
+
270
+ # The old file is still there and readable; no partial/temp file leaks.
271
+ assert store.load() == old
272
+ leftovers = list(tmp_path.glob("credentials.json.*.tmp"))
273
+ assert leftovers == []
@@ -16,7 +16,7 @@ def responder(monkeypatch: pytest.MonkeyPatch, payload: Any) -> list[str]:
16
16
  """Replace the GET and record which URLs were asked for."""
17
17
  seen: list[str] = []
18
18
 
19
- def fake_get(url: str, *, token: str, timeout: float = 30.0) -> Any:
19
+ def fake_get(url: str, *, token: str, timeout: float = 30.0, reauth: Any = None) -> Any:
20
20
  seen.append(url)
21
21
  if isinstance(payload, Exception):
22
22
  raise payload
@@ -1,10 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from urllib.error import HTTPError, URLError
4
-
5
3
  import pytest
6
4
  from rich.text import Text
7
5
 
6
+ from elva_cli.core.api.http import HttpError
8
7
  from elva_cli.core.services import whoami as whoami_service
9
8
  from elva_cli.core.services.whoami_result import WhoamiResult
10
9
  from elva_cli.errors import ApiError, AuthError
@@ -142,25 +141,26 @@ class TestRendering:
142
141
 
143
142
  class TestFetchMe:
144
143
  def test_401_is_an_auth_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
145
- def fake_urlopen(*_args: object, **_kwargs: object) -> None:
146
- raise HTTPError(BASE_URL, 401, "unauthorized", None, None) # type: ignore[arg-type]
144
+ # A 401 that survives the shared layer's refresh-and-retry.
145
+ def fake_get(*_a: object, **_k: object) -> None:
146
+ raise HttpError(401, None)
147
147
 
148
- monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
148
+ monkeypatch.setattr(whoami_service, "get_json", fake_get)
149
149
  with pytest.raises(AuthError):
150
150
  whoami_service._fetch_me(BASE_URL, "tok")
151
151
 
152
152
  def test_other_http_error_is_an_api_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
153
- def fake_urlopen(*_args: object, **_kwargs: object) -> None:
154
- raise HTTPError(BASE_URL, 500, "boom", None, None) # type: ignore[arg-type]
153
+ def fake_get(*_a: object, **_k: object) -> None:
154
+ raise HttpError(500, "boom")
155
155
 
156
- monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
156
+ monkeypatch.setattr(whoami_service, "get_json", fake_get)
157
157
  with pytest.raises(ApiError):
158
158
  whoami_service._fetch_me(BASE_URL, "tok")
159
159
 
160
160
  def test_unreachable_server_is_an_api_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
161
- def fake_urlopen(*_args: object, **_kwargs: object) -> None:
162
- raise URLError("no route to host")
161
+ def fake_get(*_a: object, **_k: object) -> None:
162
+ raise ApiError("Could not reach the server.")
163
163
 
164
- monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
164
+ monkeypatch.setattr(whoami_service, "get_json", fake_get)
165
165
  with pytest.raises(ApiError):
166
166
  whoami_service._fetch_me(BASE_URL, "tok")
File without changes
File without changes
File without changes
File without changes
File without changes