fastmcp/tests/cli/deploy/test_command.py
2026-08-07 23:50:52 -07:00

358 lines
12 KiB
Python

import json
from collections.abc import Callable
from unittest.mock import Mock
from urllib.parse import parse_qs
import httpx2
import pytest
from pydantic import SecretStr
import fastmcp
import fastmcp.cli.deploy.authentication as authentication_module
import fastmcp.cli.deploy.command as command_module
from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.deploy.credentials import CredentialStore
from fastmcp.cli.deploy.horizon_client import HorizonClient
from fastmcp.cli.deploy.state import StateFileError
class HorizonAuthAPI:
def __init__(
self,
*,
token_error: str | None = None,
revoke_status: int = 204,
invalid_api_key: str | None = None,
) -> None:
self.token_error = token_error
self.revoke_status = revoke_status
self.invalid_api_key = invalid_api_key
self.requests: list[httpx2.Request] = []
def __call__(self, request: httpx2.Request) -> httpx2.Response:
self.requests.append(request)
path = request.url.path
if path == "/api/v0/oauth/device/authorization":
return httpx2.Response(
200,
json={
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://horizon.prefect.io/oauth/device",
"verification_uri_complete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"expires_in": 600,
"interval": 1,
},
)
if path == "/api/v0/oauth/device/token":
if self.token_error is not None:
return httpx2.Response(400, json={"error": self.token_error})
return httpx2.Response(
200,
json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
)
if path == "/api/v0/me":
if request.headers.get("Authorization") == (
f"Bearer {self.invalid_api_key}"
):
return httpx2.Response(401)
return httpx2.Response(
200,
json={
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
}
},
)
if path == "/api/v0/me/api-key":
return httpx2.Response(self.revoke_status)
raise AssertionError(f"Unexpected request: {request.method} {path}")
@pytest.fixture
def use_horizon_api(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[HorizonAuthAPI], None]:
def use(api: HorizonAuthAPI) -> None:
transport = httpx2.MockTransport(api)
def client(
api_origin: str,
*,
api_key: SecretStr | str | None = None,
) -> HorizonClient:
return HorizonClient(
api_origin,
api_key=api_key,
transport=transport,
)
monkeypatch.setattr(command_module, "HorizonClient", client)
return use
@pytest.fixture(autouse=True)
def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
async def sleep(_: float) -> None:
return None
monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
async def test_json_login_writes_one_result_and_challenge_to_stderr(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
await login(json_output=True)
captured = capsys.readouterr()
stdout_lines = captured.out.strip().splitlines()
assert len(stdout_lines) == 1
assert json.loads(stdout_lines[0]) == {
"ok": True,
"command": "login",
"user": {
"id": "user-1",
"email": "ada@example.com",
"name": "Ada",
},
}
assert json.loads(captured.err) == {
"event": "device_authorization",
"verificationUrl": "https://horizon.prefect.io/oauth/device",
"verificationUrlComplete": (
"https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
),
"userCode": "ABCD-EFGH",
}
browser_open.assert_not_called()
authorization_request = next(
request
for request in api.requests
if request.url.path == "/api/v0/oauth/device/authorization"
)
assert parse_qs(authorization_request.content.decode()) == {
"client_id": ["fastmcp-cli"],
"device_name": ["Avery's laptop"],
"platform": ["darwin"],
"architecture": ["arm64"],
"client_version": ["4.0.0"],
}
state = json.loads(CredentialStore().path.read_text())
assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
assert not (fastmcp.settings.home / "cli" / "config.json").exists()
async def test_login_host_is_saved_before_device_authorization(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(host="https://dev.horizon.prefect.io/", json_output=True)
assert json.loads(capsys.readouterr().out)["ok"] is True
configuration_path = fastmcp.settings.home / "cli" / "config.json"
assert json.loads(configuration_path.read_text()) == {
"schemaVersion": 1,
"apiOrigin": "https://dev.horizon.prefect.io",
}
assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
async def test_login_rejects_an_invalid_host(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit, match="1"):
await login(host="https://horizon.prefect.io/path", json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "invalid_host"
assert CredentialStore().path.exists() is False
async def test_tty_login_survives_browser_open_failure(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI())
browser_open = Mock(side_effect=OSError("No browser"))
monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
await login()
output = capsys.readouterr().out
assert "https://horizon.prefect.io/oauth/device" in output
assert "ABCD-EFGH" in output
assert "Logged into Horizon" in output
assert "Ada" in output
assert "ada@example.com" in output
assert "Organization" not in output
browser_open.assert_called_once()
async def test_whoami_uses_the_stored_key_after_a_restart(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
await login(json_output=True)
capsys.readouterr()
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["command"] == "whoami"
assert result["user"]["email"] == "ada@example.com"
assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
async def test_login_replaces_an_invalid_stored_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
await login(json_output=True)
captured = capsys.readouterr()
assert json.loads(captured.out)["ok"] is True
assert json.loads(captured.err)["event"] == "device_authorization"
stored_key = CredentialStore().load()
assert stored_key is not None
assert stored_key.get_secret_value() == "fmcp_device_key"
async def test_login_never_persists_an_environment_key(
use_horizon_api: Callable[[HorizonAuthAPI], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
await login(json_output=True)
assert CredentialStore().path.exists() is False
assert not any(
request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
)
async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
CredentialStore().save("fmcp_stale_key")
def fail_clear(store: CredentialStore) -> None:
raise StateFileError("cleanup failed")
monkeypatch.setattr(CredentialStore, "clear", fail_clear)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "state_error"
async def test_json_whoami_does_not_start_device_authorization(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
browser_open = Mock()
monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
with pytest.raises(SystemExit, match="1"):
await whoami(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "authentication_required"
browser_open.assert_not_called()
@pytest.mark.parametrize(
("token_error", "category"),
[
("access_denied", "authorization_denied"),
("expired_token", "authorization_expired"),
],
)
async def test_json_login_reports_stable_device_failures(
token_error: str,
category: str,
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(token_error=token_error))
with pytest.raises(SystemExit, match="1"):
await login(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == category
assert CredentialStore().path.exists() is False
async def test_logout_revokes_the_remote_key_and_clears_local_state(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
api = HorizonAuthAPI()
use_horizon_api(api)
CredentialStore().save("fmcp_stored_key")
await logout(json_output=True)
assert json.loads(capsys.readouterr().out) == {
"ok": True,
"command": "logout",
"localCredentialRemoved": True,
"remoteRevoked": True,
}
assert CredentialStore().path.exists() is False
assert any(
request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
for request in api.requests
)
async def test_logout_clears_local_state_when_remote_revocation_fails(
use_horizon_api: Callable[[HorizonAuthAPI], None],
capsys: pytest.CaptureFixture[str],
) -> None:
use_horizon_api(HorizonAuthAPI(revoke_status=503))
CredentialStore().save("fmcp_stored_key")
with pytest.raises(SystemExit, match="1"):
await logout(json_output=True)
result = json.loads(capsys.readouterr().out)
assert result["error"]["category"] == "remote_revocation_failed"
assert result["localCredentialRemoved"] is True
assert result["remoteCredentialMayRemain"] is True
assert CredentialStore().path.exists() is False