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.
- elva_cli-0.5.0/CHANGELOG.md +32 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/PKG-INFO +1 -1
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/_version.py +2 -2
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/__init__.py +2 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/session.py +110 -17
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/http.py +45 -4
- elva_cli-0.5.0/src/elva_cli/core/api/identity.py +21 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/targets.py +23 -5
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth.py +2 -1
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/import_spec.py +18 -4
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/whoami.py +11 -19
- elva_cli-0.5.0/tests/unit/test_refresh_client.py +273 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_targets.py +1 -1
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_whoami_flow.py +11 -11
- {elva_cli-0.4.0 → elva_cli-0.5.0}/.gitignore +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/README.md +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/pyproject.toml +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/models.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/auth/store.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/auth.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/config.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/import_.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/commands/whoami.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/context.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth_result.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/config.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/import_result.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/services/whoami_result.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/detect.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/main.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/registry.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/console.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/output.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/auth.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/base.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/config.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/import_spec.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/whoami.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/theme.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/conftest.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_auth_logout_command.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_config_command.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_import_command.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_never_blocks.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_output_streams.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/cli/test_whoami_command.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_boundary.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_context.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_credentials.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_http.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_import_flow.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_interactivity.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_login_flow.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_logout_flow.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_output.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_prompts.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_session.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_settings_loader.py +0 -0
- {elva_cli-0.4.0 → elva_cli-0.5.0}/tests/unit/test_spec_detect.py +0 -0
- {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.
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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(
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
45
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
|
|
146
|
-
|
|
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("
|
|
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
|
|
154
|
-
raise
|
|
153
|
+
def fake_get(*_a: object, **_k: object) -> None:
|
|
154
|
+
raise HttpError(500, "boom")
|
|
155
155
|
|
|
156
|
-
monkeypatch.setattr("
|
|
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
|
|
162
|
-
raise
|
|
161
|
+
def fake_get(*_a: object, **_k: object) -> None:
|
|
162
|
+
raise ApiError("Could not reach the server.")
|
|
163
163
|
|
|
164
|
-
monkeypatch.setattr("
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|