From 3855cd87a814242185bc5713e4fca9ff8da2f7d5 Mon Sep 17 00:00:00 2001 From: fabioklr Date: Mon, 13 Apr 2026 10:52:47 +0200 Subject: [PATCH 1/6] feat: add Huly cloud (huly.app) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI was built for self-hosted Huly instances and does not work with Huly cloud (huly.app). This commit fixes three issues and adds OTP login: 1. **Accounts endpoint**: Cloud uses `account.huly.app` subdomain instead of the `/_accounts` reverse-proxy path. Detect cloud URLs and route accordingly. 2. **RPC param format**: Cloud expects named params (`{"email": ...}`) instead of positional arrays (`[email, password]`). Also `selectWorkspace` expects `workspaceUrl` key, not a positional slug. 3. **Transactor URL**: Cloud returns a per-region WebSocket endpoint in the `selectWorkspace` response (e.g. `wss://europe-tr1.huly.app`). The `/_transactor` path returns the SPA on cloud. Convert `wss://` to `https://` for REST calls, and persist it in the auth cache. 4. **OTP login (`auth login-otp`)**: Huly cloud accounts that have only used magic-link login have password login locked. Add an OTP flow that sends a code to the user's email and validates it, which also resets any failed-login lockout. Self-hosted instances are unaffected — all changes are backward-compatible. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/huly_cli/auth.py | 301 +++++++++++++++++++--------- src/huly_cli/client.py | 2 +- src/huly_cli/commands/auth_cmd.py | 71 ++++++- src/huly_cli/config.py | 6 +- tests/test_auth_cloud.py | 322 ++++++++++++++++++++++++++++++ 5 files changed, 601 insertions(+), 101 deletions(-) create mode 100644 tests/test_auth_cloud.py diff --git a/src/huly_cli/auth.py b/src/huly_cli/auth.py index 1e4e859..99f9d87 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,14 +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) + 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. + """Perform full password 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 + 1. POST accounts → login → account_token + 2. POST accounts with account_token → selectWorkspace → workspace_token + 3. POST accounts with account_token → getUserWorkspaces → workspace_uuid + 4. GET transactor/api/v1/account/{workspace_id} → account_id 5. Verify with ping """ if not config.email: @@ -27,114 +101,148 @@ 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) -> AuthCache: + """Perform OTP (email code) auth flow and cache tokens. + + The caller is responsible for sending the OTP first (via ``send_otp``) + and collecting the code into ``config.otp_code``. + """ + 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 config.otp_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": config.otp_code}, + self_hosted=[config.email, config.otp_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") - # Step 4: Get account ID - resp = await http.get( - f"{transactor_base}/api/v1/account/{workspace_id}", - headers={"Authorization": f"Bearer {workspace_token}"}, + return await _complete_login( + http, config, accounts_url, transactor_base, account_token, ) - 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}"}, + + +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], ) - ping_resp.raise_for_status() + 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. + endpoint = ws_result.get("endpoint", "") + if endpoint: + transactor_base = endpoint.replace("wss://", "https://").replace("ws://", "http://") + + 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)." + ) + 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." + ) + + 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 +250,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 +268,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..1a2b7e8 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,73 @@ 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.") + + config.otp_code = code + + try: + auth = asyncio.run(login_otp(config)) + 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..12c6b0b 100644 --- a/src/huly_cli/config.py +++ b/src/huly_cli/config.py @@ -5,7 +5,7 @@ import json import os import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -25,6 +25,7 @@ class HulyConfig: workspace: str email: str | None = None password: str | None = None + otp_code: str | None = field(default=None, repr=False) @dataclass @@ -37,6 +38,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 +115,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 +133,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..814b1f0 --- /dev/null +++ b/tests/test_auth_cloud.py @@ -0,0 +1,322 @@ +"""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): + cloud_config.otp_code = "123456" + + 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) + + assert auth.account_token == "acct" + assert auth.workspace_token == "ws" + + +async def test_login_otp_requires_code(cloud_config): + cloud_config.otp_code = None + 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_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) From d8db8c4433cf914a7993fd9c327a1c6b091932b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 15:37:27 +0200 Subject: [PATCH 2/6] test: rename 'env-pass' fixture to silence secret scanner The fixture value is a non-secret test literal, but GitGuardian's generic credential pattern flagged it. Rename to fake_test_password and add an allowlist pragma. --- tests/test_auth_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth_cmd.py b/tests/test_auth_cmd.py index a9b6b8c..197c516 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) From c5d3ba9ae35c58ed9376bf5f60294a910f6abc73 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 16:14:41 +0200 Subject: [PATCH 3/6] refactor(auth): pass OTP code to login_otp(), drop HulyConfig.otp_code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OTP code is a transient runtime value, not persistent config — pass it explicitly through the function signature instead of stashing it on the long-lived HulyConfig dataclass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/huly_cli/auth.py | 12 ++++++------ src/huly_cli/commands/auth_cmd.py | 4 +--- src/huly_cli/config.py | 3 +-- tests/test_auth_cloud.py | 7 ++----- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/huly_cli/auth.py b/src/huly_cli/auth.py index 99f9d87..8fc44ee 100644 --- a/src/huly_cli/auth.py +++ b/src/huly_cli/auth.py @@ -119,17 +119,17 @@ async def login(config: HulyConfig) -> AuthCache: ) -async def login_otp(config: HulyConfig) -> AuthCache: +async def login_otp(config: HulyConfig, code: str) -> AuthCache: """Perform OTP (email code) auth flow and cache tokens. - The caller is responsible for sending the OTP first (via ``send_otp``) - and collecting the code into ``config.otp_code``. + 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 config.otp_code: + if not code: raise AuthError("OTP code required. Check your email and pass --code.") accounts_url = _accounts_url(config) @@ -138,8 +138,8 @@ async def login_otp(config: HulyConfig) -> AuthCache: async with httpx.AsyncClient(timeout=30.0) as http: otp_params = _params( config, - cloud={"email": config.email, "code": config.otp_code}, - self_hosted=[config.email, config.otp_code], + cloud={"email": config.email, "code": code}, + self_hosted=[config.email, code], ) body = await _rpc(http, accounts_url, "validateOtp", otp_params) result = _require_result_dict(body, "OTP login") diff --git a/src/huly_cli/commands/auth_cmd.py b/src/huly_cli/commands/auth_cmd.py index 1a2b7e8..aa6a29f 100644 --- a/src/huly_cli/commands/auth_cmd.py +++ b/src/huly_cli/commands/auth_cmd.py @@ -131,10 +131,8 @@ def auth_login_otp( else: raise AuthError("OTP code required. Pass --code.") - config.otp_code = code - try: - auth = asyncio.run(login_otp(config)) + auth = asyncio.run(login_otp(config, code)) except AuthError as e: print_error(e.message) raise typer.Exit(2) from e diff --git a/src/huly_cli/config.py b/src/huly_cli/config.py index 12c6b0b..4de7737 100644 --- a/src/huly_cli/config.py +++ b/src/huly_cli/config.py @@ -5,7 +5,7 @@ import json import os import tomllib -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -25,7 +25,6 @@ class HulyConfig: workspace: str email: str | None = None password: str | None = None - otp_code: str | None = field(default=None, repr=False) @dataclass diff --git a/tests/test_auth_cloud.py b/tests/test_auth_cloud.py index 814b1f0..1df1611 100644 --- a/tests/test_auth_cloud.py +++ b/tests/test_auth_cloud.py @@ -229,8 +229,6 @@ def handler(request): async def test_login_otp_validates_and_completes(cloud_config): - cloud_config.otp_code = "123456" - def handler(request): import json as _json parsed = _json.loads(request.content) @@ -267,16 +265,15 @@ def handler(request): import unittest.mock as mock with mock.patch("huly_cli.auth.save_auth"): - auth = await auth_module.login_otp(cloud_config) + 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): - cloud_config.otp_code = None with pytest.raises(AuthError): - await auth_module.login_otp(cloud_config) + await auth_module.login_otp(cloud_config, "") # ── result hardening ───────────────────────────────────────────────────────── From c57eac1dc84e0c89244b6012d8d686ae88b3718e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 16:15:13 +0200 Subject: [PATCH 4/6] fix(auth): only apply selectWorkspace endpoint override on cloud instances Self-hosted instances reach the transactor through the nginx /_transactor reverse-proxy. If selectWorkspace ever started populating the `endpoint` field for a self-hosted server, we'd silently bypass that proxy. Gate the override on _is_cloud(config) and add a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/huly_cli/auth.py | 11 +++++++---- tests/test_auth_cloud.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/huly_cli/auth.py b/src/huly_cli/auth.py index 8fc44ee..238038a 100644 --- a/src/huly_cli/auth.py +++ b/src/huly_cli/auth.py @@ -192,10 +192,13 @@ async def _complete_login( ) # 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. - endpoint = ws_result.get("endpoint", "") - if endpoint: - transactor_base = endpoint.replace("wss://", "https://").replace("ws://", "http://") + # 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://") ws_list_params = _params(config, cloud={}, self_hosted=[]) body = await _rpc( diff --git a/tests/test_auth_cloud.py b/tests/test_auth_cloud.py index 1df1611..1bb8424 100644 --- a/tests/test_auth_cloud.py +++ b/tests/test_auth_cloud.py @@ -296,6 +296,45 @@ def handler(request): 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 From 6b7b26d3a6fedf1dff5c6ac2c89a0bdd8c5007ee Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 16:15:35 +0200 Subject: [PATCH 5/6] docs(auth): point login() docstring at _complete_login, clarify _is_cloud match The login() docstring still listed the full step sequence even though steps 2-5 moved into the shared _complete_login helper. Replace the duplicated list with a pointer to the helper so the contract stays in one place. Also add a comment on _is_cloud explaining that the leading-dot match excludes bare huly.app by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/huly_cli/auth.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/huly_cli/auth.py b/src/huly_cli/auth.py index 238038a..a0c6962 100644 --- a/src/huly_cli/auth.py +++ b/src/huly_cli/auth.py @@ -12,8 +12,10 @@ def _is_cloud(config: HulyConfig) -> bool: - """Return True if the configured URL points at Huly cloud (huly.app).""" + """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")) @@ -87,12 +89,9 @@ def _require_str(result: dict, key: str, method: str) -> str: async def login(config: HulyConfig) -> AuthCache: """Perform full password auth flow and cache tokens. - Steps: - 1. POST accounts → login → account_token - 2. POST accounts with account_token → selectWorkspace → workspace_token - 3. POST accounts with account_token → getUserWorkspaces → workspace_uuid - 4. GET transactor/api/v1/account/{workspace_id} → account_id - 5. Verify with ping + 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.") From 6ae536682df352fff795f85346bd4acce66e4ac7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 16:16:45 +0200 Subject: [PATCH 6/6] test(auth): add CliRunner tests for `auth login-otp` Mirror the existing `auth login` command-level tests so the OTP flow has parity coverage: --code provided vs send-then-prompt, send_otp AuthError exit 2, login_otp AuthError exit 2, login_otp generic Exception exit 1, and missing email/workspace in non-TTY mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_auth_cmd.py | 152 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/test_auth_cmd.py b/tests/test_auth_cmd.py index 197c516..4fa25ee 100644 --- a/tests/test_auth_cmd.py +++ b/tests/test_auth_cmd.py @@ -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 ───────────────────────────────────────────────────────────────