diff --git a/src/huly_cli/auth.py b/src/huly_cli/auth.py index 1e4e859..a0c6962 100644 --- a/src/huly_cli/auth.py +++ b/src/huly_cli/auth.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from urllib.parse import urlparse import httpx @@ -10,15 +11,87 @@ from huly_cli.errors import AuthError +def _is_cloud(config: HulyConfig) -> bool: + """Return True if the configured URL points at Huly cloud (*.huly.app).""" + parsed = urlparse(config.url) + # Match only subdomains of huly.app (e.g. app.huly.app); a bare huly.app + # host is not the cloud entry point and stays on the self-hosted code path. + return bool(parsed.hostname and parsed.hostname.endswith(".huly.app")) + + +def _accounts_url(config: HulyConfig) -> str: + """Return the accounts RPC endpoint for the given Huly instance. + + Huly cloud (huly.app) uses a dedicated ``account`` subdomain. + Self-hosted instances expose the account service at ``/_accounts`` + via an nginx reverse-proxy. + """ + if _is_cloud(config): + parsed = urlparse(config.url) + return f"{parsed.scheme or 'https'}://account.huly.app" + return f"{config.url}/_accounts" + + +def _params(config: HulyConfig, *, cloud: dict, self_hosted: list) -> dict | list: + """Return request params in the format expected by the target instance. + + Cloud accepts named params (dict); self-hosted expects positional params (list). + """ + return cloud if _is_cloud(config) else self_hosted + + +async def _rpc( + http: httpx.AsyncClient, + url: str, + method: str, + params: dict | list, + *, + token: str | None = None, + request_id: str = "1", +) -> dict: + """Send a JSON-RPC request to the Huly accounts service.""" + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + resp = await http.post( + url, + headers=headers, + json={"id": request_id, "method": method, "params": params}, + ) + resp.raise_for_status() + body = resp.json() + if "error" in body and body["error"] is not None: + raise AuthError(f"{method} failed: {body['error']}") + return body + + +def _require_result_dict(body: dict, method: str) -> dict: + result = body.get("result") + if not isinstance(result, dict): + raise AuthError( + f"{method} failed: unexpected response from accounts API " + "(missing or invalid 'result' field). The server may be " + "incompatible or a proxy may be intercepting the request." + ) + return result + + +def _require_str(result: dict, key: str, method: str) -> str: + value = result.get(key) + if not value or not isinstance(value, str): + raise AuthError( + f"{method} failed: response did not include a '{key}' " + "in the 'result' payload." + ) + return value + + async def login(config: HulyConfig) -> AuthCache: - """Perform full auth flow and cache tokens. - - Steps: - 1. POST /_accounts → login → account_token - 2. POST /_accounts with account_token → selectWorkspace → workspace_token + workspace_id - 3. POST /_accounts with account_token → getUserWorkspaces → workspace_uuid - 4. GET /_transactor/api/v1/account/{workspace_id} → account_id - 5. Verify with ping + """Perform full password auth flow and cache tokens. + + Exchanges email+password for an account token, then delegates the + workspace selection / account-id resolution / ping verification to + ``_complete_login``. """ if not config.email: raise AuthError("Email is required for login. Set HULY_EMAIL env var.") @@ -27,114 +100,151 @@ async def login(config: HulyConfig) -> AuthCache: if not config.workspace: raise AuthError("Workspace is required for login. Set HULY_WORKSPACE env var.") - accounts_url = f"{config.url}/_accounts" + accounts_url = _accounts_url(config) transactor_base = f"{config.url}/_transactor" async with httpx.AsyncClient(timeout=30.0) as http: - # Step 1: Login - resp = await http.post( - accounts_url, - json={"id": "1", "method": "login", "params": [config.email, config.password]}, + login_params = _params( + config, + cloud={"email": config.email, "password": config.password}, + self_hosted=[config.email, config.password], ) - resp.raise_for_status() - body = resp.json() - if "error" in body and body["error"] is not None: - raise AuthError(f"Login failed: {body['error']}") - if "result" not in body or not isinstance(body.get("result"), dict): - raise AuthError( - "Login failed: unexpected response from accounts API " - "(missing 'result' field). The server may be incompatible " - "or a proxy may be intercepting the request." - ) - account_token_value = body["result"].get("token") - if not account_token_value or not isinstance(account_token_value, str): - raise AuthError( - "Login failed: accounts API response did not include a 'token' " - "in the 'result' payload." - ) - account_token: str = account_token_value - - # Step 2: Select workspace - resp = await http.post( - accounts_url, - headers={"Authorization": f"Bearer {account_token}"}, - json={ - "id": "2", - "method": "selectWorkspace", - "params": [config.workspace, "external"], - }, + body = await _rpc(http, accounts_url, "login", login_params) + result = _require_result_dict(body, "Login") + account_token = _require_str(result, "token", "Login") + + return await _complete_login( + http, config, accounts_url, transactor_base, account_token, ) - resp.raise_for_status() - body = resp.json() - if "error" in body and body["error"] is not None: - raise AuthError(f"Select workspace failed: {body['error']}") - if "result" not in body or not isinstance(body.get("result"), dict): - raise AuthError( - "Select workspace failed: unexpected response from accounts API " - "(missing 'result' field)." - ) - ws_result = body["result"] - workspace_token_value = ws_result.get("token") - workspace_id_value = ws_result.get("workspaceId") - if not workspace_token_value or not isinstance(workspace_token_value, str): - raise AuthError( - "Select workspace failed: response did not include a 'token' " - "in the 'result' payload." - ) - if not workspace_id_value or not isinstance(workspace_id_value, str): - raise AuthError( - "Select workspace failed: response did not include a 'workspaceId' " - "in the 'result' payload." - ) - workspace_token: str = workspace_token_value - workspace_id: str = workspace_id_value - - # Step 3: Get workspace UUID via getUserWorkspaces - resp = await http.post( - accounts_url, - headers={"Authorization": f"Bearer {account_token}"}, - json={"id": "3", "method": "getUserWorkspaces", "params": []}, + + +async def login_otp(config: HulyConfig, code: str) -> AuthCache: + """Perform OTP (email code) auth flow and cache tokens. + + Caller is responsible for sending the OTP first via ``send_otp`` and + collecting the code (e.g. from a prompt or ``--code`` flag). + """ + if not config.email: + raise AuthError("Email is required for OTP login. Set HULY_EMAIL env var.") + if not config.workspace: + raise AuthError("Workspace is required for login. Set HULY_WORKSPACE env var.") + if not code: + raise AuthError("OTP code required. Check your email and pass --code.") + + accounts_url = _accounts_url(config) + transactor_base = f"{config.url}/_transactor" + + async with httpx.AsyncClient(timeout=30.0) as http: + otp_params = _params( + config, + cloud={"email": config.email, "code": code}, + self_hosted=[config.email, code], ) - resp.raise_for_status() - body = resp.json() - if "error" in body and body["error"] is not None: - raise AuthError(f"getUserWorkspaces failed: {body['error']}") - workspace_uuid = "" - for ws in body.get("result", []) or []: - if ws.get("workspaceUrl") == config.workspace: - workspace_uuid = ws.get("uuid", "") - break - if not workspace_uuid: - raise AuthError( - f"Could not find UUID for workspace '{config.workspace}' in getUserWorkspaces response." - ) + body = await _rpc(http, accounts_url, "validateOtp", otp_params) + result = _require_result_dict(body, "OTP login") + account_token = _require_str(result, "token", "OTP login") + + return await _complete_login( + http, config, accounts_url, transactor_base, account_token, + ) + + +async def send_otp(config: HulyConfig) -> None: + """Request an OTP code to be sent to the user's email.""" + if not config.email: + raise AuthError("Email is required. Set HULY_EMAIL env var.") + + accounts_url = _accounts_url(config) + async with httpx.AsyncClient(timeout=30.0) as http: + params = _params( + config, + cloud={"email": config.email}, + self_hosted=[config.email], + ) + await _rpc(http, accounts_url, "loginOtp", params) + + +async def _complete_login( + http: httpx.AsyncClient, + config: HulyConfig, + accounts_url: str, + transactor_base: str, + account_token: str, +) -> AuthCache: + """Complete login after obtaining an account token (from password or OTP).""" + select_params = _params( + config, + cloud={"workspaceUrl": config.workspace, "kind": "external"}, + self_hosted=[config.workspace, "external"], + ) + body = await _rpc( + http, accounts_url, "selectWorkspace", select_params, + token=account_token, request_id="2", + ) + ws_result = _require_result_dict(body, "Select workspace") + workspace_token = _require_str(ws_result, "token", "Select workspace") + workspace_id = ws_result.get("workspaceId") or ws_result.get("workspace", "") + if not isinstance(workspace_id, str) or not workspace_id: + raise AuthError( + "Select workspace failed: response did not include a 'workspaceId' " + "in the 'result' payload." + ) + + # Cloud returns a per-region transactor endpoint (e.g. wss://europe-tr1.huly.app). + # Use it for REST calls instead of the default /_transactor path. Self-hosted + # instances reach the transactor through the nginx /_transactor reverse-proxy, + # so leave the base URL alone even if the response carries an `endpoint` field. + if _is_cloud(config): + endpoint = ws_result.get("endpoint", "") + if endpoint: + transactor_base = endpoint.replace("wss://", "https://").replace("ws://", "http://") - # Step 4: Get account ID - resp = await http.get( - f"{transactor_base}/api/v1/account/{workspace_id}", - headers={"Authorization": f"Bearer {workspace_token}"}, + ws_list_params = _params(config, cloud={}, self_hosted=[]) + body = await _rpc( + http, accounts_url, "getUserWorkspaces", ws_list_params, + token=account_token, request_id="3", + ) + workspaces = body.get("result") or [] + if not isinstance(workspaces, list): + raise AuthError( + "getUserWorkspaces failed: unexpected response shape (expected list)." ) - resp.raise_for_status() - account_data = resp.json() - # account endpoint returns the account object; _id is the account ID - account_id: str = account_data.get("_id", account_data.get("id", "")) - - # Step 5: Ping to verify - ping_resp = await http.get( - f"{transactor_base}/api/v1/ping/{workspace_id}", - headers={"Authorization": f"Bearer {workspace_token}"}, + workspace_uuid = "" + for ws in workspaces: + if not isinstance(ws, dict): + continue + if ws.get("workspaceUrl") == config.workspace or ws.get("url") == config.workspace: + workspace_uuid = ws.get("uuid", "") + break + if not workspace_uuid: + raise AuthError( + f"Could not find UUID for workspace '{config.workspace}' in getUserWorkspaces response." ) - ping_resp.raise_for_status() + + resp = await http.get( + f"{transactor_base}/api/v1/account/{workspace_id}", + headers={"Authorization": f"Bearer {workspace_token}"}, + ) + resp.raise_for_status() + account_data = resp.json() + account_id: str = account_data.get("_id", account_data.get("id", "")) + + ping_resp = await http.get( + f"{transactor_base}/api/v1/ping/{workspace_id}", + headers={"Authorization": f"Bearer {workspace_token}"}, + ) + ping_resp.raise_for_status() auth = AuthCache( account_token=account_token, workspace_token=workspace_token, workspace_id=workspace_id, workspace_uuid=workspace_uuid, - email=config.email, + email=config.email or "", workspace_slug=config.workspace, account_id=account_id, cached_at=time.time(), + transactor_base=transactor_base, ) save_auth(auth) return auth @@ -142,7 +252,7 @@ async def login(config: HulyConfig) -> AuthCache: async def _ping(config: HulyConfig, auth: AuthCache) -> bool: """Ping the transactor to verify the cached token is still valid.""" - transactor_base = f"{config.url}/_transactor" + transactor_base = auth.transactor_base or f"{config.url}/_transactor" try: async with httpx.AsyncClient(timeout=10.0) as http: resp = await http.get( @@ -160,7 +270,6 @@ async def ensure_auth(config: HulyConfig) -> AuthCache: if cached is not None: if await _ping(config, cached): return cached - # Token stale — re-login if we have credentials if not config.email or not config.password: raise AuthError( "Cached token is invalid and no credentials available to re-authenticate. " diff --git a/src/huly_cli/client.py b/src/huly_cli/client.py index dd5171c..08d1c57 100644 --- a/src/huly_cli/client.py +++ b/src/huly_cli/client.py @@ -23,7 +23,7 @@ class HulyClient: def __init__(self, config: HulyConfig, auth: AuthCache) -> None: self._config = config self._auth = auth - self._transactor_base = f"{config.url}/_transactor" + self._transactor_base = auth.transactor_base or f"{config.url}/_transactor" self._http = httpx.AsyncClient( base_url=self._transactor_base, headers={"Authorization": f"Bearer {auth.workspace_token}"}, diff --git a/src/huly_cli/commands/auth_cmd.py b/src/huly_cli/commands/auth_cmd.py index ad691de..aa6a29f 100644 --- a/src/huly_cli/commands/auth_cmd.py +++ b/src/huly_cli/commands/auth_cmd.py @@ -1,4 +1,4 @@ -"""Auth commands: login, status.""" +"""Auth commands: login, login-otp, status.""" from __future__ import annotations @@ -8,7 +8,7 @@ import typer -from huly_cli.auth import check_auth_status, login +from huly_cli.auth import check_auth_status, login, login_otp, send_otp from huly_cli.config import load_config, save_config from huly_cli.errors import AuthError from huly_cli.output import print_error, print_item, print_success @@ -81,6 +81,71 @@ def auth_login( ) +@app.command("login-otp") +def auth_login_otp( + ctx: typer.Context, + email: Annotated[ + str | None, + typer.Option("--email", "-e", help="Huly account email.", envvar="HULY_EMAIL"), + ] = None, + code: Annotated[ + str | None, + typer.Option("--code", "-c", help="OTP code from email."), + ] = None, +) -> None: + """Log in via email OTP code. Sends a code to your email, then validates it.""" + overrides: dict = ctx.obj or {} + config = load_config( + url_override=overrides.get("url"), + workspace_override=overrides.get("workspace"), + ) + + # Resolve email + resolved_email = email or config.email + if not resolved_email: + if sys.stdin.isatty(): + resolved_email = typer.prompt("Email") + else: + raise AuthError("Email required. Set --email or HULY_EMAIL env var.") + + # Resolve workspace + if not config.workspace: + if sys.stdin.isatty(): + config.workspace = typer.prompt("Workspace slug") + else: + raise AuthError("Workspace required. Set HULY_WORKSPACE env var.") + + config.email = resolved_email + + if not code: + # Send OTP, then prompt for code + try: + asyncio.run(send_otp(config)) + except AuthError as e: + print_error(e.message) + raise typer.Exit(2) from e + + typer.echo(f"OTP code sent to {config.email}") + if sys.stdin.isatty(): + code = typer.prompt("Code") + else: + raise AuthError("OTP code required. Pass --code.") + + try: + auth = asyncio.run(login_otp(config, code)) + except AuthError as e: + print_error(e.message) + raise typer.Exit(2) from e + except Exception as e: + print_error(f"OTP login failed: {e}") + raise typer.Exit(1) from e + + save_config(config) + print_success( + f"Logged in as [bold]{auth.email}[/bold] to workspace [bold]{auth.workspace_slug}[/bold]" + ) + + @app.command("status") def auth_status(ctx: typer.Context) -> None: """Show current authentication status.""" diff --git a/src/huly_cli/config.py b/src/huly_cli/config.py index ed2b601..4de7737 100644 --- a/src/huly_cli/config.py +++ b/src/huly_cli/config.py @@ -37,6 +37,7 @@ class AuthCache: workspace_slug: str account_id: str # for modifiedBy in write ops cached_at: float # unix timestamp + transactor_base: str = "" # REST base URL for transactor def _load_toml_config() -> dict[str, Any]: @@ -113,6 +114,7 @@ def load_auth() -> AuthCache | None: workspace_slug=data["workspace_slug"], account_id=data["account_id"], cached_at=data["cached_at"], + transactor_base=data.get("transactor_base", ""), ) except (KeyError, json.JSONDecodeError, OSError): return None @@ -130,6 +132,7 @@ def save_auth(auth: AuthCache) -> None: "workspace_slug": auth.workspace_slug, "account_id": auth.account_id, "cached_at": auth.cached_at, + "transactor_base": auth.transactor_base, } AUTH_FILE.write_text(json.dumps(data, indent=2)) AUTH_FILE.chmod(0o600) diff --git a/tests/test_auth_cloud.py b/tests/test_auth_cloud.py new file mode 100644 index 0000000..1bb8424 --- /dev/null +++ b/tests/test_auth_cloud.py @@ -0,0 +1,358 @@ +"""Unit tests for Huly cloud support and OTP login in `huly_cli.auth`. + +Covers: +- `_accounts_url` endpoint selection (cloud vs self-hosted) +- Param-format detection: cloud accepts named params (dict), self-hosted + requires positional params (list) for login, selectWorkspace, + getUserWorkspaces, validateOtp, and loginOtp. +- `_rpc` error handling edge cases ({"error": null} must NOT raise). +- OTP flow: `send_otp` and `login_otp`. +- Cloud `endpoint` override for transactor base URL. +""" + +from __future__ import annotations + +import pytest +import respx +import httpx + +from huly_cli import auth as auth_module +from huly_cli.config import HulyConfig +from huly_cli.errors import AuthError + +SELF_HOSTED_ACCOUNTS_URL = "https://test.example.com/_accounts" +CLOUD_ACCOUNTS_URL = "https://account.huly.app" + + +@pytest.fixture +def cloud_config(): + return HulyConfig( + url="https://app.huly.app", + workspace="acme", + email="u@example.com", + password="pw", + ) + + +# ── endpoint + cloud detection ─────────────────────────────────────────────── + + +def test_accounts_url_self_hosted(fake_config): + assert auth_module._accounts_url(fake_config) == SELF_HOSTED_ACCOUNTS_URL + + +def test_accounts_url_cloud(cloud_config): + assert auth_module._accounts_url(cloud_config) == CLOUD_ACCOUNTS_URL + + +def test_is_cloud_detection(fake_config, cloud_config): + assert auth_module._is_cloud(cloud_config) is True + assert auth_module._is_cloud(fake_config) is False + + +def test_params_helper_shape(cloud_config, fake_config): + cloud = auth_module._params( + cloud_config, + cloud={"email": "x"}, + self_hosted=["x"], + ) + assert cloud == {"email": "x"} + + self_hosted = auth_module._params( + fake_config, + cloud={"email": "x"}, + self_hosted=["x"], + ) + assert self_hosted == ["x"] + + +# ── _rpc behavior ──────────────────────────────────────────────────────────── + + +async def test_rpc_null_error_does_not_raise(): + """{"error": null} is valid — _rpc must not raise on it.""" + with respx.mock: + respx.post(SELF_HOSTED_ACCOUNTS_URL).respond( + json={"error": None, "result": {"ok": True}} + ) + async with httpx.AsyncClient() as http: + body = await auth_module._rpc( + http, SELF_HOSTED_ACCOUNTS_URL, "login", {} + ) + assert body["result"] == {"ok": True} + + +async def test_rpc_non_null_error_raises(): + with respx.mock: + respx.post(SELF_HOSTED_ACCOUNTS_URL).respond( + json={"error": "bad creds"} + ) + async with httpx.AsyncClient() as http: + with pytest.raises(AuthError) as exc: + await auth_module._rpc( + http, SELF_HOSTED_ACCOUNTS_URL, "login", {} + ) + assert "bad creds" in str(exc.value) + + +# ── self-hosted: positional params ─────────────────────────────────────────── + + +async def test_self_hosted_login_sends_positional_params(fake_config): + """Self-hosted login must send params as a list, not a dict.""" + captured = {} + + def handler(request): + body = request.content + import json as _json + parsed = _json.loads(body) + captured.setdefault("login_params", []).append(parsed["params"]) + method = parsed["method"] + if method == "login": + return httpx.Response(200, json={"result": {"token": "acct-tok"}}) + if method == "selectWorkspace": + return httpx.Response(200, json={ + "result": {"token": "ws-tok", "workspaceId": "w-1"} + }) + if method == "getUserWorkspaces": + return httpx.Response(200, json={ + "result": [{"workspaceUrl": "test-ws", "uuid": "uuid-1"}] + }) + return httpx.Response(500) + + with respx.mock: + respx.post(SELF_HOSTED_ACCOUNTS_URL).mock(side_effect=handler) + respx.get( + "https://test.example.com/_transactor/api/v1/account/w-1" + ).respond(json={"_id": "acc-1"}) + respx.get( + "https://test.example.com/_transactor/api/v1/ping/w-1" + ).respond(200) + + # save_auth writes to disk; patch it out. + import unittest.mock as mock + with mock.patch("huly_cli.auth.save_auth"): + await auth_module.login(fake_config) + + # First call is login → must be list form + assert isinstance(captured["login_params"][0], list) + assert captured["login_params"][0] == ["test@test.com", "testpass"] + # Second call is selectWorkspace → list form + assert isinstance(captured["login_params"][1], list) + assert captured["login_params"][1] == ["test-ws", "external"] + # Third call is getUserWorkspaces → empty list + assert captured["login_params"][2] == [] + + +# ── cloud: named params ────────────────────────────────────────────────────── + + +async def test_cloud_login_sends_named_params(cloud_config): + """Cloud login must send params as a dict.""" + captured = [] + + def handler(request): + import json as _json + parsed = _json.loads(request.content) + captured.append((parsed["method"], parsed["params"])) + method = parsed["method"] + if method == "login": + return httpx.Response(200, json={"result": {"token": "acct"}}) + if method == "selectWorkspace": + return httpx.Response(200, json={ + "result": { + "token": "ws", + "workspaceId": "w-1", + "endpoint": "wss://europe-tr1.huly.app", + } + }) + if method == "getUserWorkspaces": + return httpx.Response(200, json={ + "result": [{"workspaceUrl": "acme", "uuid": "uuid-1"}] + }) + return httpx.Response(500) + + with respx.mock: + respx.post(CLOUD_ACCOUNTS_URL).mock(side_effect=handler) + respx.get( + "https://europe-tr1.huly.app/api/v1/account/w-1" + ).respond(json={"_id": "acc-1"}) + respx.get( + "https://europe-tr1.huly.app/api/v1/ping/w-1" + ).respond(200) + + import unittest.mock as mock + with mock.patch("huly_cli.auth.save_auth") as save: + auth = await auth_module.login(cloud_config) + + methods = [c[0] for c in captured] + assert methods[:3] == ["login", "selectWorkspace", "getUserWorkspaces"] + assert captured[0][1] == {"email": "u@example.com", "password": "pw"} + assert captured[1][1] == {"workspaceUrl": "acme", "kind": "external"} + assert captured[2][1] == {} + # transactor_base was overridden by cloud endpoint + assert auth.transactor_base == "https://europe-tr1.huly.app" + save.assert_called_once() + + +# ── OTP flow ───────────────────────────────────────────────────────────────── + + +async def test_send_otp_cloud_uses_named_params(cloud_config): + captured = [] + + def handler(request): + import json as _json + captured.append(_json.loads(request.content)["params"]) + return httpx.Response(200, json={"result": None}) + + with respx.mock: + respx.post(CLOUD_ACCOUNTS_URL).mock(side_effect=handler) + await auth_module.send_otp(cloud_config) + + assert captured == [{"email": "u@example.com"}] + + +async def test_send_otp_self_hosted_uses_positional_params(fake_config): + captured = [] + + def handler(request): + import json as _json + captured.append(_json.loads(request.content)["params"]) + return httpx.Response(200, json={"result": None}) + + with respx.mock: + respx.post(SELF_HOSTED_ACCOUNTS_URL).mock(side_effect=handler) + await auth_module.send_otp(fake_config) + + assert captured == [["test@test.com"]] + + +async def test_login_otp_validates_and_completes(cloud_config): + def handler(request): + import json as _json + parsed = _json.loads(request.content) + method = parsed["method"] + if method == "validateOtp": + # Ensure named params were sent + assert parsed["params"] == { + "email": "u@example.com", + "code": "123456", + } + return httpx.Response(200, json={"result": {"token": "acct"}}) + if method == "selectWorkspace": + return httpx.Response(200, json={ + "result": { + "token": "ws", + "workspaceId": "w-1", + "endpoint": "wss://europe-tr1.huly.app", + } + }) + if method == "getUserWorkspaces": + return httpx.Response(200, json={ + "result": [{"workspaceUrl": "acme", "uuid": "uuid-1"}] + }) + return httpx.Response(500) + + with respx.mock: + respx.post(CLOUD_ACCOUNTS_URL).mock(side_effect=handler) + respx.get( + "https://europe-tr1.huly.app/api/v1/account/w-1" + ).respond(json={"_id": "acc-1"}) + respx.get( + "https://europe-tr1.huly.app/api/v1/ping/w-1" + ).respond(200) + + import unittest.mock as mock + with mock.patch("huly_cli.auth.save_auth"): + auth = await auth_module.login_otp(cloud_config, "123456") + + assert auth.account_token == "acct" + assert auth.workspace_token == "ws" + + +async def test_login_otp_requires_code(cloud_config): + with pytest.raises(AuthError): + await auth_module.login_otp(cloud_config, "") + + +# ── result hardening ───────────────────────────────────────────────────────── + + +async def test_select_workspace_missing_token_raises_auth_error(cloud_config): + def handler(request): + import json as _json + method = _json.loads(request.content)["method"] + if method == "login": + return httpx.Response(200, json={"result": {"token": "acct"}}) + # selectWorkspace returns no token + return httpx.Response(200, json={"result": {}}) + + with respx.mock: + respx.post(CLOUD_ACCOUNTS_URL).mock(side_effect=handler) + with pytest.raises(AuthError) as exc: + await auth_module.login(cloud_config) + + assert "token" in str(exc.value).lower() + + +async def test_self_hosted_ignores_select_workspace_endpoint(fake_config): + """Self-hosted login must keep its /_transactor base even if the response carries an endpoint.""" + def handler(request): + import json as _json + method = _json.loads(request.content)["method"] + if method == "login": + return httpx.Response(200, json={"result": {"token": "acct-tok"}}) + if method == "selectWorkspace": + # Hypothetical future server-side change that populates endpoint. + return httpx.Response(200, json={ + "result": { + "token": "ws-tok", + "workspaceId": "w-1", + "endpoint": "wss://internal-transactor.example.com", + } + }) + if method == "getUserWorkspaces": + return httpx.Response(200, json={ + "result": [{"workspaceUrl": "test-ws", "uuid": "uuid-1"}] + }) + return httpx.Response(500) + + with respx.mock: + respx.post(SELF_HOSTED_ACCOUNTS_URL).mock(side_effect=handler) + # Asserts the override was NOT applied: requests still go via /_transactor. + respx.get( + "https://test.example.com/_transactor/api/v1/account/w-1" + ).respond(json={"_id": "acc-1"}) + respx.get( + "https://test.example.com/_transactor/api/v1/ping/w-1" + ).respond(200) + + import unittest.mock as mock + with mock.patch("huly_cli.auth.save_auth"): + auth = await auth_module.login(fake_config) + + assert auth.transactor_base == "https://test.example.com/_transactor" + + +async def test_cloud_getuserworkspaces_missing_workspace_raises(cloud_config): + def handler(request): + import json as _json + method = _json.loads(request.content)["method"] + if method == "login": + return httpx.Response(200, json={"result": {"token": "acct"}}) + if method == "selectWorkspace": + return httpx.Response(200, json={ + "result": {"token": "ws", "workspaceId": "w-1"} + }) + # workspace list does not contain the requested slug + return httpx.Response(200, json={"result": [ + {"workspaceUrl": "other-ws", "uuid": "other-uuid"} + ]}) + + with respx.mock: + respx.post(CLOUD_ACCOUNTS_URL).mock(side_effect=handler) + with pytest.raises(AuthError) as exc: + await auth_module.login(cloud_config) + + assert "acme" in str(exc.value) diff --git a/tests/test_auth_cmd.py b/tests/test_auth_cmd.py index a9b6b8c..4fa25ee 100644 --- a/tests/test_auth_cmd.py +++ b/tests/test_auth_cmd.py @@ -67,7 +67,7 @@ def test_auth_login_uses_config_credentials_when_flags_omitted(): url="https://huly.example.com", workspace="fake-ws", email="env-user@example.com", - password="env-pass", + password="fake_test_password", # pragma: allowlist secret ) login_mock = AsyncMock(return_value=FAKE_AUTH) @@ -191,6 +191,158 @@ def test_auth_login_propagates_generic_error_with_exit_code_1(): login_mock.assert_awaited_once() +# ── auth login-otp ──────────────────────────────────────────────────────────── + + +def _otp_config() -> HulyConfig: + return HulyConfig( + url="https://app.huly.app", + workspace="fake-ws", + email="user@example.com", + password=None, + ) + + +def test_auth_login_otp_with_code_flag_skips_send(): + """--code provided: send_otp must NOT be called; login_otp completes the flow.""" + fake_config = _otp_config() + send_mock = AsyncMock() + login_otp_mock = AsyncMock(return_value=FAKE_AUTH) + save_config_mock = MagicMock() + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", save_config_mock), + patch("huly_cli.commands.auth_cmd.send_otp", send_mock), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp", "--code", "123456"]) + + assert result.exit_code == 0, result.output + assert "Logged in as" in result.output + send_mock.assert_not_awaited() + login_otp_mock.assert_awaited_once() + # config arg first, code arg second + assert login_otp_mock.await_args.args[1] == "123456" + save_config_mock.assert_called_once() + + +def test_auth_login_otp_without_code_in_non_tty_errors_after_send(): + """No --code in non-TTY: send_otp runs, then the prompt fallback raises and exits non-zero.""" + fake_config = _otp_config() + send_mock = AsyncMock() + login_otp_mock = AsyncMock(return_value=FAKE_AUTH) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", send_mock), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp"]) + + assert result.exit_code != 0 + login_otp_mock.assert_not_awaited() + + +def test_auth_login_otp_send_otp_auth_error_exits_2(): + """send_otp raising AuthError surfaces as exit code 2; login_otp is never reached.""" + fake_config = _otp_config() + send_mock = AsyncMock(side_effect=AuthError("Email not found")) + login_otp_mock = AsyncMock(return_value=FAKE_AUTH) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", send_mock), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp"]) + + assert result.exit_code == 2 + send_mock.assert_awaited_once() + login_otp_mock.assert_not_awaited() + + +def test_auth_login_otp_login_otp_auth_error_exits_2(): + """AuthError raised from login_otp() surfaces as exit code 2.""" + fake_config = _otp_config() + login_otp_mock = AsyncMock(side_effect=AuthError("Bad code")) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", AsyncMock()), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp", "--code", "000000"]) + + assert result.exit_code == 2 + login_otp_mock.assert_awaited_once() + + +def test_auth_login_otp_generic_error_exits_1(): + """A non-AuthError exception from login_otp surfaces as exit code 1.""" + fake_config = _otp_config() + login_otp_mock = AsyncMock(side_effect=RuntimeError("network down")) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", AsyncMock()), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp", "--code", "123456"]) + + assert result.exit_code == 1 + login_otp_mock.assert_awaited_once() + + +def test_auth_login_otp_errors_when_email_missing_in_non_tty(): + """auth login-otp without email and without TTY should exit non-zero before send_otp.""" + fake_config = HulyConfig( + url="https://app.huly.app", + workspace="fake-ws", + email=None, + ) + send_mock = AsyncMock() + login_otp_mock = AsyncMock(return_value=FAKE_AUTH) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", send_mock), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp", "--code", "123456"]) + + assert result.exit_code != 0 + send_mock.assert_not_awaited() + login_otp_mock.assert_not_awaited() + + +def test_auth_login_otp_errors_when_workspace_missing_in_non_tty(): + """auth login-otp with email but no workspace (non-tty) should exit non-zero.""" + fake_config = HulyConfig( + url="https://app.huly.app", + workspace="", + email="user@example.com", + ) + send_mock = AsyncMock() + login_otp_mock = AsyncMock(return_value=FAKE_AUTH) + + with ( + patch("huly_cli.commands.auth_cmd.load_config", MagicMock(return_value=fake_config)), + patch("huly_cli.commands.auth_cmd.save_config", MagicMock()), + patch("huly_cli.commands.auth_cmd.send_otp", send_mock), + patch("huly_cli.commands.auth_cmd.login_otp", login_otp_mock), + ): + result = runner.invoke(app, ["auth", "login-otp", "--code", "123456"]) + + assert result.exit_code != 0 + login_otp_mock.assert_not_awaited() + + # ── auth status ───────────────────────────────────────────────────────────────