Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hud/cli/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ def _request_device_code(client: httpx.Client, hud_console: HUDConsole) -> dict[
hud_console.info(f"HUD_API_URL={_api_url()}")
raise typer.Exit(1) from exc

if response.status_code == 404:
hud_console.error(f"No browser login at {_api_url()}{DEVICE_CODE_PATH} (404).")
hud_console.info("This HUD deployment does not serve the device flow.")
hud_console.info(f"Get your key at: {settings.hud_web_url}/settings")
hud_console.info("Set it via: hud set HUD_API_KEY=your-key-here")
raise typer.Exit(1)
if response.status_code != 200:
hud_console.error(f"HUD API returned {response.status_code} when starting login.")
hud_console.info(response.text[:500])
Expand Down
69 changes: 69 additions & 0 deletions hud/cli/tests/test_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""CLI behavior when the platform serves no browser login.

``hud login`` speaks the device flow (RFC 8628) to ``/auth/device/code``. A
deployment that does not serve that endpoint answers 404, and the command used
to print the status code and the body, which reads as an outage rather than as
the one thing the user can do about it: authenticate with an API key instead.

The transport is mocked rather than the module's own helpers, so the command
runs its real request path and the assertions are on what a user sees.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import httpx
from typer.testing import CliRunner

from hud.cli import app
from hud.cli import login as login_module

if TYPE_CHECKING:
import pytest

runner = CliRunner()


# Bound before any test replaces the name, so the factory below builds a real
# client rather than calling itself.
_HTTPX_CLIENT = httpx.Client


def _client_serving(response: httpx.Response):
"""A drop-in ``httpx.Client`` whose every request gets *response*."""

def factory(*args: object, **kwargs: object) -> httpx.Client:
return _HTTPX_CLIENT(transport=httpx.MockTransport(lambda _request: response))

return factory


def test_login_points_at_an_api_key_where_the_device_flow_is_not_served(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
login_module.httpx,
"Client",
_client_serving(httpx.Response(404, json={"error": "not_found"})),
)

result = runner.invoke(app, ["login", "--quiet"])

assert result.exit_code == 1
assert "404" in result.output
assert "hud set HUD_API_KEY" in result.output


def test_login_reports_any_other_failure_as_it_finds_it(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
login_module.httpx,
"Client",
_client_serving(httpx.Response(503, text="upstream is down")),
)

result = runner.invoke(app, ["login", "--quiet"])

assert result.exit_code == 1
assert "503" in result.output
assert "upstream is down" in result.output