mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx2
|
|
import pytest
|
|
|
|
from fastmcp.cli.deploy.authentication import (
|
|
DeviceAuthorizationDeniedError,
|
|
DeviceAuthorizationExpiredError,
|
|
authorize_device,
|
|
poll_device_authorization,
|
|
)
|
|
from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonClient
|
|
|
|
|
|
class Clock:
|
|
def __init__(self) -> None:
|
|
self.now = 0.0
|
|
self.sleeps: list[float] = []
|
|
|
|
def monotonic(self) -> float:
|
|
return self.now
|
|
|
|
async def sleep(self, delay: float) -> None:
|
|
self.sleeps.append(delay)
|
|
self.now += delay
|
|
|
|
|
|
def authorization(*, expires_in: int = 600, interval: int = 5) -> DeviceAuthorization:
|
|
return DeviceAuthorization(
|
|
device_code="device-secret",
|
|
user_code="BCDF-GHJK",
|
|
verification_uri="https://horizon.prefect.io/oauth/device",
|
|
verification_uri_complete=(
|
|
"https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK"
|
|
),
|
|
expires_in=expires_in,
|
|
interval=interval,
|
|
)
|
|
|
|
|
|
def sequenced_transport(
|
|
responses: list[httpx2.Response],
|
|
) -> httpx2.MockTransport:
|
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
return responses.pop(0)
|
|
|
|
return httpx2.MockTransport(handler)
|
|
|
|
|
|
async def test_polling_handles_pending_slow_down_and_approval() -> None:
|
|
clock = Clock()
|
|
async with HorizonClient(
|
|
transport=sequenced_transport(
|
|
[
|
|
httpx2.Response(400, json={"error": "authorization_pending"}),
|
|
httpx2.Response(400, json={"error": "slow_down"}),
|
|
httpx2.Response(
|
|
200,
|
|
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
|
|
),
|
|
]
|
|
)
|
|
) as client:
|
|
api_key = await poll_device_authorization(
|
|
client,
|
|
authorization(),
|
|
sleep=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
)
|
|
|
|
assert api_key.get_secret_value() == "fmcp_secret"
|
|
assert clock.sleeps == [5, 5, 10]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "exception"),
|
|
[
|
|
("access_denied", DeviceAuthorizationDeniedError),
|
|
("expired_token", DeviceAuthorizationExpiredError),
|
|
],
|
|
)
|
|
async def test_polling_handles_terminal_errors(
|
|
error: str,
|
|
exception: type[Exception],
|
|
) -> None:
|
|
clock = Clock()
|
|
async with HorizonClient(
|
|
transport=sequenced_transport([httpx2.Response(400, json={"error": error})])
|
|
) as client:
|
|
with pytest.raises(exception):
|
|
await poll_device_authorization(
|
|
client,
|
|
authorization(),
|
|
sleep=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
)
|
|
|
|
|
|
async def test_polling_stops_at_the_local_expiry_deadline() -> None:
|
|
clock = Clock()
|
|
requests: list[httpx2.Request] = []
|
|
|
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
requests.append(request)
|
|
return httpx2.Response(400, json={"error": "authorization_pending"})
|
|
|
|
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
|
|
with pytest.raises(DeviceAuthorizationExpiredError):
|
|
await poll_device_authorization(
|
|
client,
|
|
authorization(expires_in=5, interval=5),
|
|
sleep=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
)
|
|
|
|
assert requests == []
|
|
|
|
|
|
async def test_authorize_device_presents_challenge_before_opening_browser() -> None:
|
|
events: list[str] = []
|
|
clock = Clock()
|
|
|
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
if request.url.path.endswith("/authorization"):
|
|
return httpx2.Response(200, json=authorization().model_dump())
|
|
return httpx2.Response(
|
|
200,
|
|
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
|
|
)
|
|
|
|
def present(challenge: DeviceAuthorization) -> None:
|
|
events.append(f"present:{challenge.user_code}")
|
|
|
|
def open_browser(url: str) -> None:
|
|
events.append(f"browser:{url}")
|
|
|
|
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
|
|
await authorize_device(
|
|
client,
|
|
on_challenge=present,
|
|
open_browser=True,
|
|
browser_opener=open_browser,
|
|
sleep=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
)
|
|
|
|
assert events == [
|
|
"present:BCDF-GHJK",
|
|
"browser:https://horizon.prefect.io/oauth/device?user_code=BCDF-GHJK",
|
|
]
|
|
|
|
|
|
async def test_browser_failure_does_not_stop_remote_login() -> None:
|
|
clock = Clock()
|
|
|
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
if request.url.path.endswith("/authorization"):
|
|
return httpx2.Response(200, json=authorization().model_dump())
|
|
return httpx2.Response(
|
|
200,
|
|
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
|
|
)
|
|
|
|
def fail_to_open(url: str) -> None:
|
|
raise OSError("no browser")
|
|
|
|
async with HorizonClient(transport=httpx2.MockTransport(handler)) as client:
|
|
api_key = await authorize_device(
|
|
client,
|
|
open_browser=True,
|
|
browser_opener=fail_to_open,
|
|
sleep=clock.sleep,
|
|
monotonic=clock.monotonic,
|
|
)
|
|
|
|
assert api_key.get_secret_value() == "fmcp_secret"
|