menda-cli 0.2.2__tar.gz → 0.2.4__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.
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.github/workflows/ci.yml +3 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/CHANGELOG.md +12 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/PKG-INFO +1 -1
- {menda_cli-0.2.2 → menda_cli-0.2.4}/pyproject.toml +1 -1
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/api.py +6 -0
- menda_cli-0.2.4/src/menda/auth/http.py +98 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/oauth2/client.py +31 -5
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/task.py +9 -2
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/logging.py +7 -1
- menda_cli-0.2.4/tests/auth/oauth2/test_client.py +82 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/auth/test_login.py +4 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/uv.lock +1 -1
- menda_cli-0.2.2/src/menda/auth/http.py +0 -35
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.cruft.json +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.github/workflows/lint-pr.yml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.github/workflows/prepare-release.yml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.github/workflows/release.yml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/.gitignore +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/README.md +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/cliff.toml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/env.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/exceptions.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/inquirer.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/mendacfg.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/models.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/oauth2/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/oauth2/token.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/auth/pkce.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/auth/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/auth/commands.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/console.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/cookiecutter.json +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/.gitignore +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/README.md +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/menda.yaml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/pyproject.toml +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/dp/.gitkeep +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/orchestration/.gitkeep +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/discovery.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/exceptions.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/init/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/init/commands.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/init/prompts.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/init/templates.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/main.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/params.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_env.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_exceptions.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_mendacfg.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_models.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_pkce.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_task.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/auth/test_token.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/.gitkeep +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/auth/test_configure.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/auth/test_whoami.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/init/__init__.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/init/test_commands.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/init/test_templates.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_console.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_discovery.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_exceptions.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_ipython.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_main.py +0 -0
- {menda_cli-0.2.2 → menda_cli-0.2.4}/tests/cli/test_params.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.4] - 2026-04-21
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Menda 622 fix device auth login issues (#18) ([59cb101](https://github.com/mendaml/menda-cli/commit/59cb1015bc7535c1bcaa0134b19d05d1ece7b44e))
|
|
10
|
+
|
|
11
|
+
## [0.2.3] - 2026-04-14
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
- Implement fetch_tenant_config and update device authorization flow (#16) ([9c31568](https://github.com/mendaml/menda-cli/commit/9c315689d07f24996928f7ab641e98de1c598b26))
|
|
16
|
+
|
|
5
17
|
## [0.2.2] - 2026-04-06
|
|
6
18
|
|
|
7
19
|
### Features
|
|
@@ -6,6 +6,7 @@ AUTHORIZATION_PATH = "/auth/device/authorize"
|
|
|
6
6
|
TOKEN_PATH = "/auth/device/token" # noqa: S105
|
|
7
7
|
WHOAMI_PATH = "/auth/whoami"
|
|
8
8
|
OAUTH2_TOKEN_PATH = "/auth/oauth2/token" # noqa: S105
|
|
9
|
+
CONFIG_PATH = "/auth/discovery"
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class AuthRoutes:
|
|
@@ -82,6 +83,11 @@ class AuthRoutes:
|
|
|
82
83
|
payload["code_verifier"] = code_verifier
|
|
83
84
|
return session.post(url=OAUTH2_TOKEN_PATH, params=payload)
|
|
84
85
|
|
|
86
|
+
@classmethod
|
|
87
|
+
def config(cls, session: requests.Session, hostname: str) -> requests.Response:
|
|
88
|
+
"""Get tenant configuration by hostname."""
|
|
89
|
+
return session.get(CONFIG_PATH, params={"hostname": hostname})
|
|
90
|
+
|
|
85
91
|
@classmethod
|
|
86
92
|
def whoami(cls, session: requests.Session, token: str) -> requests.Response:
|
|
87
93
|
"""Get current user info."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""HTTP session factory for auth API calls."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
from functools import partial
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from menda.cli.exceptions import NetworkError
|
|
10
|
+
|
|
11
|
+
API_VERSION = "/api/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomRequestsSession(requests.Session):
|
|
15
|
+
"""Requests session with custom methods."""
|
|
16
|
+
|
|
17
|
+
def delete_with_payload(self, **kwargs: typing.Any) -> requests.Response: # noqa: ANN401
|
|
18
|
+
"""DELETE with request body support."""
|
|
19
|
+
return self.request(method="DELETE", **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _translate_connection_error(err: requests.exceptions.RequestException, base_url: str) -> NetworkError:
|
|
23
|
+
"""Convert a low-level requests exception into a user-friendly NetworkError."""
|
|
24
|
+
host = urlparse(base_url).hostname or base_url
|
|
25
|
+
|
|
26
|
+
if isinstance(err, requests.exceptions.SSLError):
|
|
27
|
+
return NetworkError(
|
|
28
|
+
message=f"TLS/SSL handshake failed when contacting '{host}'.",
|
|
29
|
+
context=f"network:ssl:{host}",
|
|
30
|
+
suggestion=(
|
|
31
|
+
"Check that the host URL in your mendacfg profile is correct "
|
|
32
|
+
"and that your system clock and CA certificates are up to date."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if isinstance(err, requests.exceptions.ConnectTimeout):
|
|
37
|
+
return NetworkError(
|
|
38
|
+
message=f"Timed out while connecting to '{host}'.",
|
|
39
|
+
context=f"network:timeout:{host}",
|
|
40
|
+
suggestion="Check your network connection or try again in a moment.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if isinstance(err, requests.exceptions.ReadTimeout):
|
|
44
|
+
return NetworkError(
|
|
45
|
+
message=f"Timed out while waiting for a response from '{host}'.",
|
|
46
|
+
context=f"network:timeout:{host}",
|
|
47
|
+
suggestion="The service may be slow or unavailable — please try again.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if isinstance(err, requests.exceptions.ConnectionError):
|
|
51
|
+
cause = str(err.__cause__) if err.__cause__ else str(err)
|
|
52
|
+
is_dns = "resolve" in cause.lower() or "name or service" in cause.lower() or "nodename" in cause.lower()
|
|
53
|
+
if is_dns:
|
|
54
|
+
return NetworkError(
|
|
55
|
+
message=f"Could not resolve '{host}'.",
|
|
56
|
+
context=f"network:dns:{host}",
|
|
57
|
+
suggestion=(
|
|
58
|
+
"Check the 'host' value in your mendacfg profile and confirm you have internet connectivity."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
return NetworkError(
|
|
62
|
+
message=f"Could not connect to '{host}'.",
|
|
63
|
+
context=f"network:connection:{host}",
|
|
64
|
+
suggestion=("Verify the host is reachable and that the 'host' value in your mendacfg profile is correct."),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return NetworkError(
|
|
68
|
+
message=f"Network error while contacting '{host}': {err}",
|
|
69
|
+
context=f"network:other:{host}",
|
|
70
|
+
suggestion="Check your network connection and profile configuration.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def requests_session(base_url: str) -> CustomRequestsSession:
|
|
75
|
+
"""Create a requests session with a base URL prepended to all requests.
|
|
76
|
+
|
|
77
|
+
Connection-layer failures (DNS, TLS, timeouts, refused connections) are
|
|
78
|
+
translated into NetworkError so users see a concise, actionable message
|
|
79
|
+
instead of a requests/urllib3 traceback.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def new_request(
|
|
83
|
+
base_url: str,
|
|
84
|
+
f: typing.Callable,
|
|
85
|
+
method: str,
|
|
86
|
+
url: str,
|
|
87
|
+
*args: typing.Any, # noqa: ANN401
|
|
88
|
+
**kwargs: typing.Any, # noqa: ANN401
|
|
89
|
+
) -> requests.Response:
|
|
90
|
+
try:
|
|
91
|
+
return f(method, base_url + API_VERSION + url, *args, **kwargs)
|
|
92
|
+
except requests.exceptions.RequestException as err:
|
|
93
|
+
raise _translate_connection_error(err, base_url) from err
|
|
94
|
+
|
|
95
|
+
base_url = "" if base_url is None else base_url.rstrip("/")
|
|
96
|
+
s = CustomRequestsSession()
|
|
97
|
+
s.request = partial(new_request, base_url, s.request) # ty:ignore[invalid-assignment]
|
|
98
|
+
return s
|
|
@@ -13,13 +13,38 @@ from menda.auth.api import AuthRoutes
|
|
|
13
13
|
from menda.auth.exceptions import AuthenticationError
|
|
14
14
|
|
|
15
15
|
logger = structlog.get_logger()
|
|
16
|
-
|
|
17
|
-
CLIENT_ID = "device"
|
|
18
16
|
SCOPE = ["openid"]
|
|
19
17
|
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
20
18
|
CALLBACK_PATH = "/auth/device/callback"
|
|
21
19
|
|
|
22
20
|
|
|
21
|
+
def fetch_tenant_config(session: requests.Session, host: str) -> str:
|
|
22
|
+
"""Fetch app_client_id for the tenant from the config endpoint."""
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
hostname = urlparse(host).hostname
|
|
26
|
+
if not hostname:
|
|
27
|
+
raise AuthenticationError(detail=f"Invalid host URL: {host}")
|
|
28
|
+
response = AuthRoutes.config(session=session, hostname=hostname)
|
|
29
|
+
try:
|
|
30
|
+
body = response.json()
|
|
31
|
+
except Exception as err:
|
|
32
|
+
raise AuthenticationError(
|
|
33
|
+
code=response.status_code,
|
|
34
|
+
detail=f"Failed to resolve tenant config for '{hostname}': server returned non-JSON response (status {response.status_code})",
|
|
35
|
+
) from err
|
|
36
|
+
if response.status_code == 200:
|
|
37
|
+
app_client_id = body.get("app_client_id")
|
|
38
|
+
if not app_client_id:
|
|
39
|
+
raise AuthenticationError(
|
|
40
|
+
code=response.status_code,
|
|
41
|
+
detail=f"Tenant config for '{hostname}' is missing app_client_id",
|
|
42
|
+
)
|
|
43
|
+
return app_client_id
|
|
44
|
+
detail = body.get("detail", "Failed to resolve tenant config")
|
|
45
|
+
raise AuthenticationError(code=response.status_code, detail=detail)
|
|
46
|
+
|
|
47
|
+
|
|
23
48
|
def poll_for_token(interval: int, expires_in: int, api: typing.Callable[[], requests.Response]) -> requests.Response:
|
|
24
49
|
"""Poll an API endpoint at intervals until success or timeout."""
|
|
25
50
|
deadline = time.time() + expires_in
|
|
@@ -42,10 +67,10 @@ def is_device_code_in_valid_state(state: str) -> bool:
|
|
|
42
67
|
return state in ["authorized", "pending"]
|
|
43
68
|
|
|
44
69
|
|
|
45
|
-
def authorize_device(session: requests.Session, code_challenge: str) -> models.DeviceAuthResponseModel:
|
|
70
|
+
def authorize_device(session: requests.Session, code_challenge: str, client_id: str) -> models.DeviceAuthResponseModel:
|
|
46
71
|
"""Request device authorization."""
|
|
47
72
|
response = AuthRoutes.authorize_device(
|
|
48
|
-
session=session, client_id=
|
|
73
|
+
session=session, client_id=client_id, scope=SCOPE, code_challenge=code_challenge, code_challenge_method="S256"
|
|
49
74
|
)
|
|
50
75
|
if response and response.status_code == 200:
|
|
51
76
|
return models.DeviceAuthResponseModel(**response.json())
|
|
@@ -63,6 +88,7 @@ def login_with_device(
|
|
|
63
88
|
device_model: models.DeviceAuthResponseModel,
|
|
64
89
|
host: str,
|
|
65
90
|
code_verifier: str,
|
|
91
|
+
client_id: str,
|
|
66
92
|
) -> models.CredentialsModel:
|
|
67
93
|
"""Complete device flow login."""
|
|
68
94
|
response = poll_for_token(
|
|
@@ -70,7 +96,7 @@ def login_with_device(
|
|
|
70
96
|
expires_in=device_model.expires_in,
|
|
71
97
|
api=lambda: AuthRoutes.token(
|
|
72
98
|
session=session,
|
|
73
|
-
client_id=
|
|
99
|
+
client_id=client_id,
|
|
74
100
|
redirect_uri=urljoin(host, CALLBACK_PATH),
|
|
75
101
|
device_code=device_model.device_code,
|
|
76
102
|
grant_type=GRANT_TYPE,
|
|
@@ -145,13 +145,20 @@ def login(console: Console, profile_id: str | None) -> None:
|
|
|
145
145
|
session = requests_session(base_url=profile_data.host)
|
|
146
146
|
code_verifier, code_challenge = pkce.generate_pkce_pair(48)
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
# Resolve real app_client_id before device auth
|
|
149
|
+
client_id = client.fetch_tenant_config(session=session, host=profile_data.host)
|
|
150
|
+
|
|
151
|
+
device_model = client.authorize_device(session=session, code_challenge=code_challenge, client_id=client_id)
|
|
149
152
|
verification_url = urljoin(profile_data.host, device_model.verification_uri_complete)
|
|
150
153
|
console.info(f"Opening browser for authentication: {verification_url}")
|
|
151
154
|
client.open_browser(verification_url)
|
|
152
155
|
|
|
153
156
|
credentials = client.login_with_device(
|
|
154
|
-
session=session,
|
|
157
|
+
session=session,
|
|
158
|
+
device_model=device_model,
|
|
159
|
+
host=profile_data.host,
|
|
160
|
+
code_verifier=code_verifier,
|
|
161
|
+
client_id=client_id,
|
|
155
162
|
)
|
|
156
163
|
|
|
157
164
|
token.cache_token(credentials=credentials, profile_id=profile_id)
|
|
@@ -10,6 +10,7 @@ the CLI package.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import structlog
|
|
13
|
+
from structlog.dev import ConsoleRenderer, RichTracebackFormatter, plain_traceback
|
|
13
14
|
from structlog.processors import ExceptionRenderer
|
|
14
15
|
from structlog.typing import Processor
|
|
15
16
|
|
|
@@ -49,9 +50,14 @@ def configure_logging(log_level: str, json_output: bool = False, show_locals: bo
|
|
|
49
50
|
structlog.processors.JSONRenderer(),
|
|
50
51
|
]
|
|
51
52
|
else:
|
|
53
|
+
# structlog's default rich formatter has show_locals=True, which leaks
|
|
54
|
+
# internal state (config values, tokens, etc.) into end-user tracebacks.
|
|
55
|
+
# Opt into a formatter that honors our flag and defaults to off.
|
|
56
|
+
exception_formatter = RichTracebackFormatter(show_locals=True) if show_locals else plain_traceback
|
|
52
57
|
renderers = [
|
|
53
|
-
|
|
58
|
+
ConsoleRenderer(
|
|
54
59
|
colors=True,
|
|
60
|
+
exception_formatter=exception_formatter,
|
|
55
61
|
level_styles={
|
|
56
62
|
"debug": "\033[36m",
|
|
57
63
|
"info": "\033[32m",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for fetch_tenant_config."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from menda.auth.exceptions import AuthenticationError
|
|
8
|
+
from menda.auth.oauth2.client import fetch_tenant_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mock_response(status_code: int, json_data: dict | None = None, json_raises: bool = False) -> MagicMock:
|
|
12
|
+
response = MagicMock()
|
|
13
|
+
response.status_code = status_code
|
|
14
|
+
if json_raises:
|
|
15
|
+
response.json.side_effect = ValueError("No JSON")
|
|
16
|
+
else:
|
|
17
|
+
response.json.return_value = json_data
|
|
18
|
+
return response
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestFetchTenantConfig:
|
|
22
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
23
|
+
def test_returns_app_client_id_on_success(self, mock_config: MagicMock) -> None:
|
|
24
|
+
session = MagicMock()
|
|
25
|
+
mock_config.return_value = _mock_response(200, {"app_client_id": "abc123", "userpool_id": "us-east-1_xyz"})
|
|
26
|
+
|
|
27
|
+
result = fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
|
|
28
|
+
|
|
29
|
+
assert result == "abc123"
|
|
30
|
+
mock_config.assert_called_once_with(session=session, hostname="acme.cloud.mendaml.com")
|
|
31
|
+
|
|
32
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
33
|
+
def test_raises_on_empty_app_client_id(self, mock_config: MagicMock) -> None:
|
|
34
|
+
session = MagicMock()
|
|
35
|
+
mock_config.return_value = _mock_response(200, {"app_client_id": "", "userpool_id": "us-east-1_xyz"})
|
|
36
|
+
|
|
37
|
+
with pytest.raises(AuthenticationError, match="missing app_client_id"):
|
|
38
|
+
fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
|
|
39
|
+
|
|
40
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
41
|
+
def test_raises_on_missing_app_client_id(self, mock_config: MagicMock) -> None:
|
|
42
|
+
session = MagicMock()
|
|
43
|
+
mock_config.return_value = _mock_response(200, {"userpool_id": "us-east-1_xyz"})
|
|
44
|
+
|
|
45
|
+
with pytest.raises(AuthenticationError, match="missing app_client_id"):
|
|
46
|
+
fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
|
|
47
|
+
|
|
48
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
49
|
+
def test_raises_on_non_json_response(self, mock_config: MagicMock) -> None:
|
|
50
|
+
session = MagicMock()
|
|
51
|
+
mock_config.return_value = _mock_response(200, json_raises=True)
|
|
52
|
+
|
|
53
|
+
with pytest.raises(AuthenticationError, match="non-JSON response"):
|
|
54
|
+
fetch_tenant_config(session, "http://localhost:3000")
|
|
55
|
+
|
|
56
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
57
|
+
def test_raises_on_404(self, mock_config: MagicMock) -> None:
|
|
58
|
+
session = MagicMock()
|
|
59
|
+
mock_config.return_value = _mock_response(
|
|
60
|
+
404, {"detail": "No tenant configuration found for hostname: unknown"}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with pytest.raises(AuthenticationError, match="No tenant configuration found"):
|
|
64
|
+
fetch_tenant_config(session, "https://unknown.mendaml.com")
|
|
65
|
+
|
|
66
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
67
|
+
def test_raises_on_server_error(self, mock_config: MagicMock) -> None:
|
|
68
|
+
session = MagicMock()
|
|
69
|
+
mock_config.return_value = _mock_response(500, {"detail": "Internal Server Error"})
|
|
70
|
+
|
|
71
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
72
|
+
fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
|
|
73
|
+
assert exc_info.value.code == 500
|
|
74
|
+
|
|
75
|
+
@patch("menda.auth.oauth2.client.AuthRoutes.config")
|
|
76
|
+
def test_parses_hostname_from_url(self, mock_config: MagicMock) -> None:
|
|
77
|
+
session = MagicMock()
|
|
78
|
+
mock_config.return_value = _mock_response(200, {"app_client_id": "abc123"})
|
|
79
|
+
|
|
80
|
+
fetch_tenant_config(session, "https://acme.cloud.mendaml.com:8443/some/path")
|
|
81
|
+
|
|
82
|
+
mock_config.assert_called_once_with(session=session, hostname="acme.cloud.mendaml.com")
|
|
@@ -43,6 +43,7 @@ class TestLoginCommand:
|
|
|
43
43
|
|
|
44
44
|
self.env_patcher.stop()
|
|
45
45
|
|
|
46
|
+
@mock.patch("menda.auth.oauth2.client.fetch_tenant_config", return_value="test-app-client-id")
|
|
46
47
|
@mock.patch("menda.auth.oauth2.client.authorize_device")
|
|
47
48
|
@mock.patch("menda.auth.oauth2.client.open_browser")
|
|
48
49
|
@mock.patch("menda.auth.oauth2.client.login_with_device")
|
|
@@ -55,6 +56,7 @@ class TestLoginCommand:
|
|
|
55
56
|
mock_login_with_device: mock.Mock,
|
|
56
57
|
mock_open_browser: mock.Mock,
|
|
57
58
|
mock_authorize_device: mock.Mock,
|
|
59
|
+
mock_fetch_tenant_config: mock.Mock,
|
|
58
60
|
setup_and_teardown: pytest.FixtureRequest,
|
|
59
61
|
) -> None:
|
|
60
62
|
"""Test successful login flow."""
|
|
@@ -93,6 +95,7 @@ class TestLoginCommand:
|
|
|
93
95
|
mock_cache_token.assert_called_once_with(credentials=credentials, profile_id="test_profile")
|
|
94
96
|
logger.info("All mocks verified successfully")
|
|
95
97
|
|
|
98
|
+
@mock.patch("menda.auth.oauth2.client.fetch_tenant_config", return_value="test-app-client-id")
|
|
96
99
|
@mock.patch("menda.auth.oauth2.client.authorize_device")
|
|
97
100
|
@mock.patch("menda.auth.oauth2.client.open_browser")
|
|
98
101
|
@mock.patch("menda.auth.oauth2.client.login_with_device")
|
|
@@ -103,6 +106,7 @@ class TestLoginCommand:
|
|
|
103
106
|
mock_login_with_device: mock.Mock,
|
|
104
107
|
mock_open_browser: mock.Mock,
|
|
105
108
|
mock_authorize_device: mock.Mock,
|
|
109
|
+
mock_fetch_tenant_config: mock.Mock,
|
|
106
110
|
setup_and_teardown: pytest.FixtureRequest,
|
|
107
111
|
) -> None:
|
|
108
112
|
"""Test login flow with authentication error."""
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""HTTP session factory for auth API calls."""
|
|
2
|
-
|
|
3
|
-
import typing
|
|
4
|
-
from functools import partial
|
|
5
|
-
|
|
6
|
-
import requests
|
|
7
|
-
|
|
8
|
-
API_VERSION = "/api/v1"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class CustomRequestsSession(requests.Session):
|
|
12
|
-
"""Requests session with custom methods."""
|
|
13
|
-
|
|
14
|
-
def delete_with_payload(self, **kwargs: typing.Any) -> requests.Response: # noqa: ANN401
|
|
15
|
-
"""DELETE with request body support."""
|
|
16
|
-
return self.request(method="DELETE", **kwargs)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def requests_session(base_url: str) -> CustomRequestsSession:
|
|
20
|
-
"""Create a requests session with a base URL prepended to all requests."""
|
|
21
|
-
|
|
22
|
-
def new_request(
|
|
23
|
-
base_url: str,
|
|
24
|
-
f: typing.Callable,
|
|
25
|
-
method: str,
|
|
26
|
-
url: str,
|
|
27
|
-
*args: typing.Any, # noqa: ANN401
|
|
28
|
-
**kwargs: typing.Any, # noqa: ANN401
|
|
29
|
-
) -> requests.Response:
|
|
30
|
-
return f(method, base_url + API_VERSION + url, *args, **kwargs)
|
|
31
|
-
|
|
32
|
-
base_url = "" if base_url is None else base_url.rstrip("/")
|
|
33
|
-
s = CustomRequestsSession()
|
|
34
|
-
s.request = partial(new_request, base_url, s.request) # ty:ignore[invalid-assignment]
|
|
35
|
-
return s
|
|
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
|
{menda_cli-0.2.2 → menda_cli-0.2.4}/src/menda/cli/cookiecutters/databricks-default/cookiecutter.json
RENAMED
|
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
|