mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 18:22:07 +02:00
Add Prefect Horizon authentication client and local state (#4785)
* feat: add Horizon authentication client and state * fix: apply Windows state ACLs to existing descriptors * fix: distinguish public route authorization failures * fix: harden Horizon state boundaries
This commit is contained in:
parent
6475650fc7
commit
bba8c44f7b
10 changed files with 1751 additions and 0 deletions
176
tests/cli/deploy/test_authentication.py
Normal file
176
tests/cli/deploy/test_authentication.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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"
|
||||
131
tests/cli/deploy/test_configuration.py
Normal file
131
tests/cli/deploy/test_configuration.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.cli.deploy.configuration import (
|
||||
ConfigurationStore,
|
||||
HorizonConfiguration,
|
||||
)
|
||||
from fastmcp.cli.deploy.credentials import CredentialStore
|
||||
from fastmcp.cli.deploy.horizon_client import DEFAULT_HORIZON_API_ORIGIN
|
||||
from fastmcp.cli.deploy.state import StateFileError
|
||||
|
||||
|
||||
def test_configuration_defaults_to_the_production_origin(tmp_path: Path) -> None:
|
||||
store = ConfigurationStore(tmp_path)
|
||||
|
||||
configuration = store.load()
|
||||
|
||||
assert configuration.api_origin == DEFAULT_HORIZON_API_ORIGIN
|
||||
assert store.path.exists() is False
|
||||
|
||||
|
||||
def test_configuration_stores_only_schema_and_api_origin(tmp_path: Path) -> None:
|
||||
store = ConfigurationStore(tmp_path)
|
||||
configuration = HorizonConfiguration(
|
||||
schemaVersion=1,
|
||||
apiOrigin="https://example.com/",
|
||||
)
|
||||
|
||||
store.save(configuration)
|
||||
|
||||
assert json.loads(store.path.read_text()) == {
|
||||
"schemaVersion": 1,
|
||||
"apiOrigin": "https://example.com",
|
||||
}
|
||||
assert store.load() == configuration
|
||||
|
||||
|
||||
def test_configuration_rejects_organization_state(tmp_path: Path) -> None:
|
||||
store = ConfigurationStore(tmp_path)
|
||||
store.path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiOrigin": DEFAULT_HORIZON_API_ORIGIN,
|
||||
"currentOrganizationId": "org-id",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(StateFileError):
|
||||
store.load()
|
||||
|
||||
|
||||
def test_origin_change_clears_credentials_before_writing_configuration(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
credentials = CredentialStore(tmp_path)
|
||||
credentials.save("fmcp_secret")
|
||||
configuration = ConfigurationStore(tmp_path)
|
||||
configuration.save(
|
||||
HorizonConfiguration(
|
||||
schemaVersion=1,
|
||||
apiOrigin=DEFAULT_HORIZON_API_ORIGIN,
|
||||
)
|
||||
)
|
||||
events: list[str] = []
|
||||
original_clear = credentials.clear
|
||||
original_save = configuration.save
|
||||
|
||||
def clear() -> None:
|
||||
events.append("clear")
|
||||
original_clear()
|
||||
|
||||
def save(value: HorizonConfiguration) -> None:
|
||||
events.append("save")
|
||||
original_save(value)
|
||||
|
||||
monkeypatch.setattr(credentials, "clear", clear)
|
||||
monkeypatch.setattr(configuration, "save", save)
|
||||
|
||||
result = configuration.set_api_origin(
|
||||
"https://dev.horizon.prefect.io",
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
assert events == ["clear", "save"]
|
||||
assert credentials.load() is None
|
||||
assert result.api_origin == "https://dev.horizon.prefect.io"
|
||||
|
||||
|
||||
def test_same_origin_does_not_clear_credentials(tmp_path: Path) -> None:
|
||||
credentials = CredentialStore(tmp_path)
|
||||
credentials.save("fmcp_secret")
|
||||
configuration = ConfigurationStore(tmp_path)
|
||||
|
||||
configuration.set_api_origin(
|
||||
DEFAULT_HORIZON_API_ORIGIN,
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
stored = credentials.load()
|
||||
assert stored is not None
|
||||
assert stored.get_secret_value() == "fmcp_secret"
|
||||
|
||||
|
||||
def test_failed_origin_write_leaves_no_cross_origin_credential(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
credentials = CredentialStore(tmp_path)
|
||||
credentials.save("fmcp_secret")
|
||||
configuration = ConfigurationStore(tmp_path)
|
||||
|
||||
def fail_save(value: HorizonConfiguration) -> None:
|
||||
raise StateFileError("write failed")
|
||||
|
||||
monkeypatch.setattr(configuration, "save", fail_save)
|
||||
|
||||
with pytest.raises(StateFileError):
|
||||
configuration.set_api_origin(
|
||||
"https://dev.horizon.prefect.io",
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
assert credentials.load() is None
|
||||
assert configuration.load().api_origin == DEFAULT_HORIZON_API_ORIGIN
|
||||
309
tests/cli/deploy/test_credentials.py
Normal file
309
tests/cli/deploy/test_credentials.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from fastmcp.cli.deploy.configuration import (
|
||||
ConfigurationStore,
|
||||
HorizonConfiguration,
|
||||
)
|
||||
from fastmcp.cli.deploy.credentials import (
|
||||
AuthenticationRequiredError,
|
||||
CredentialStore,
|
||||
resolve_credential,
|
||||
revoke_and_clear_credential,
|
||||
)
|
||||
from fastmcp.cli.deploy.horizon_client import HorizonClient, HorizonUnavailableError
|
||||
from fastmcp.cli.deploy.state import (
|
||||
StateFileError,
|
||||
_restrict_windows_access,
|
||||
)
|
||||
|
||||
|
||||
def load_secret(store: CredentialStore) -> SecretStr:
|
||||
secret = store.load()
|
||||
assert secret is not None
|
||||
return secret
|
||||
|
||||
|
||||
def test_credential_store_writes_only_the_approved_contract(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_secret")
|
||||
|
||||
assert json.loads(store.path.read_text()) == {
|
||||
"schemaVersion": 1,
|
||||
"apiKey": "fmcp_secret",
|
||||
}
|
||||
assert load_secret(store).get_secret_value() == "fmcp_secret"
|
||||
assert "user" not in store.path.read_text()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
|
||||
def test_credential_store_restricts_file_and_directory_modes(tmp_path: Path) -> None:
|
||||
state_directory = tmp_path / "cli"
|
||||
store = CredentialStore(state_directory)
|
||||
store.save("fmcp_secret")
|
||||
|
||||
assert store.path.stat().st_mode & 0o777 == 0o600
|
||||
assert state_directory.stat().st_mode & 0o777 == 0o700
|
||||
|
||||
|
||||
def test_credential_store_restricts_an_existing_secret_file(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.path.write_text('{"schemaVersion": 1, "apiKey": "fmcp_secret"}')
|
||||
if os.name != "nt":
|
||||
store.path.chmod(0o644)
|
||||
|
||||
assert load_secret(store).get_secret_value() == "fmcp_secret"
|
||||
if os.name != "nt":
|
||||
assert store.path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX directory fsync")
|
||||
def test_atomic_write_ignores_unsupported_directory_fsync(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
original_fsync = os.fsync
|
||||
|
||||
def fsync(descriptor: int) -> None:
|
||||
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||
raise OSError(errno.EINVAL, "directory sync is not supported")
|
||||
original_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr("fastmcp.cli.deploy.state.os.fsync", fsync)
|
||||
|
||||
store.save("fmcp_secret")
|
||||
|
||||
assert load_secret(store).get_secret_value() == "fmcp_secret"
|
||||
|
||||
|
||||
def test_atomic_write_preserves_previous_state_on_replace_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_original")
|
||||
|
||||
def fail_replace(source: Path, destination: Path) -> None:
|
||||
raise OSError("replace failed")
|
||||
|
||||
monkeypatch.setattr("fastmcp.cli.deploy.state.os.replace", fail_replace)
|
||||
with pytest.raises(StateFileError):
|
||||
store.save("fmcp_new")
|
||||
|
||||
assert json.loads(store.path.read_text())["apiKey"] == "fmcp_original"
|
||||
assert list(tmp_path.glob(".*.tmp")) == []
|
||||
|
||||
|
||||
async def test_environment_credential_takes_precedence_and_is_not_stored(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_stored")
|
||||
authorize_called = False
|
||||
|
||||
async def authorize() -> SecretStr:
|
||||
nonlocal authorize_called
|
||||
authorize_called = True
|
||||
return SecretStr("fmcp_interactive")
|
||||
|
||||
result = await resolve_credential(
|
||||
store,
|
||||
environ={"HORIZON_API_KEY": "fmcp_environment"},
|
||||
authorize=authorize,
|
||||
)
|
||||
|
||||
assert result.source == "environment"
|
||||
assert result.api_key.get_secret_value() == "fmcp_environment"
|
||||
assert load_secret(store).get_secret_value() == "fmcp_stored"
|
||||
assert authorize_called is False
|
||||
|
||||
|
||||
async def test_stored_credential_precedes_interactive_authorization(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_stored")
|
||||
|
||||
async def authorize() -> SecretStr:
|
||||
raise AssertionError("interactive authorization must not run")
|
||||
|
||||
result = await resolve_credential(store, environ={}, authorize=authorize)
|
||||
|
||||
assert result.source == "stored"
|
||||
assert result.api_key.get_secret_value() == "fmcp_stored"
|
||||
|
||||
|
||||
async def test_interactive_credential_is_persisted(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
|
||||
async def authorize() -> SecretStr:
|
||||
return SecretStr("fmcp_interactive")
|
||||
|
||||
result = await resolve_credential(store, environ={}, authorize=authorize)
|
||||
|
||||
assert result.source == "interactive"
|
||||
assert load_secret(store).get_secret_value() == "fmcp_interactive"
|
||||
|
||||
|
||||
async def test_interactive_credential_rejects_an_origin_change(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
ConfigurationStore(tmp_path).save(
|
||||
HorizonConfiguration(
|
||||
schemaVersion=1,
|
||||
apiOrigin="https://dev.horizon.prefect.io",
|
||||
)
|
||||
)
|
||||
|
||||
async def authorize() -> SecretStr:
|
||||
return SecretStr("fmcp_old_origin")
|
||||
|
||||
with pytest.raises(StateFileError, match="host changed"):
|
||||
await resolve_credential(
|
||||
store,
|
||||
environ={},
|
||||
authorize=authorize,
|
||||
expected_api_origin="https://horizon.prefect.io",
|
||||
)
|
||||
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
|
||||
with pytest.raises(AuthenticationRequiredError):
|
||||
await resolve_credential(CredentialStore(tmp_path), environ={})
|
||||
|
||||
|
||||
async def test_remote_revoke_always_removes_the_local_credential(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.save("fmcp_stored")
|
||||
|
||||
def unavailable(request: httpx2.Request) -> httpx2.Response:
|
||||
raise httpx2.ConnectError("offline", request=request)
|
||||
|
||||
async with HorizonClient(
|
||||
api_key="fmcp_stored",
|
||||
transport=httpx2.MockTransport(unavailable),
|
||||
) as client:
|
||||
with pytest.raises(HorizonUnavailableError):
|
||||
await revoke_and_clear_credential(client, store)
|
||||
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_windows_acl_replaces_the_existing_access_list(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path = tmp_path / "auth.json"
|
||||
path.write_text("{}")
|
||||
calls: list[list[str]] = []
|
||||
state_paths: list[str] = []
|
||||
|
||||
def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(command)
|
||||
environment = cast(dict[str, str], kwargs["env"])
|
||||
state_paths.append(environment["FASTMCP_STATE_PATH"])
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
monkeypatch.setattr("fastmcp.cli.deploy.state.subprocess.run", run)
|
||||
_restrict_windows_access(path)
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
calls[0][5],
|
||||
]
|
||||
]
|
||||
assert state_paths == [str(path)]
|
||||
assert "$path = $env:FASTMCP_STATE_PATH" in calls[0][5]
|
||||
assert "Get-Acl -LiteralPath $path" in calls[0][5]
|
||||
assert "SetAccessRuleProtection($true, $false)" in calls[0][5]
|
||||
assert "RemoveAccessRuleSpecific($existingRule)" in calls[0][5]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows ACL inspection")
|
||||
def test_windows_credential_state_allows_only_the_current_user(tmp_path: Path) -> None:
|
||||
state_directory = tmp_path / "cli"
|
||||
store = CredentialStore(state_directory)
|
||||
store.save("fmcp_secret")
|
||||
inspect_acl = r"""
|
||||
$acl = Get-Acl -LiteralPath $env:FASTMCP_STATE_PATH
|
||||
$current = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$access = @($acl.Access | ForEach-Object {
|
||||
$_.IdentityReference.Translate(
|
||||
[System.Security.Principal.SecurityIdentifier]
|
||||
).Value
|
||||
})
|
||||
[pscustomobject]@{
|
||||
current = $current
|
||||
access = $access
|
||||
protected = $acl.AreAccessRulesProtected
|
||||
inherited = @($acl.Access | ForEach-Object { $_.IsInherited })
|
||||
} | ConvertTo-Json -Compress
|
||||
"""
|
||||
|
||||
for path in (state_directory, store.path):
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
inspect_acl,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "FASTMCP_STATE_PATH": str(path)},
|
||||
)
|
||||
acl = json.loads(result.stdout)
|
||||
assert set(acl["access"]) == {acl["current"]}
|
||||
assert acl["protected"] is True
|
||||
assert not any(acl["inherited"])
|
||||
|
||||
|
||||
def test_credential_store_rejects_empty_api_keys(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
|
||||
for api_key in ("", " "):
|
||||
with pytest.raises(StateFileError):
|
||||
store.save(api_key)
|
||||
|
||||
assert store.path.exists() is False
|
||||
|
||||
|
||||
def test_malformed_credential_state_has_a_safe_error(tmp_path: Path) -> None:
|
||||
store = CredentialStore(tmp_path)
|
||||
store.path.write_text(
|
||||
'{"schemaVersion": 1, "apiKey": "fmcp_valid", "metadata": "fmcp_secret"}'
|
||||
)
|
||||
|
||||
with pytest.raises(StateFileError) as exc_info:
|
||||
store.load()
|
||||
|
||||
formatted_exception = "".join(traceback.format_exception(exc_info.value))
|
||||
assert "fmcp_secret" not in formatted_exception
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__suppress_context__ is True
|
||||
265
tests/cli/deploy/test_horizon_client.py
Normal file
265
tests/cli/deploy/test_horizon_client.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from fastmcp.cli.deploy.horizon_client import (
|
||||
DEVICE_AUTH_CLIENT_ID,
|
||||
DEVICE_AUTH_GRANT_TYPE,
|
||||
DeviceMetadata,
|
||||
HorizonClient,
|
||||
HorizonResponseError,
|
||||
HorizonUnauthorizedError,
|
||||
normalize_api_origin,
|
||||
)
|
||||
|
||||
|
||||
def mock_transport(
|
||||
handler: Callable[[httpx2.Request], httpx2.Response],
|
||||
) -> httpx2.MockTransport:
|
||||
return httpx2.MockTransport(handler)
|
||||
|
||||
|
||||
async def test_device_authorization_uses_the_oauth_form_contract() -> None:
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
assert request.url.path == "/api/v0/oauth/device/authorization"
|
||||
assert request.headers["content-type"].startswith(
|
||||
"application/x-www-form-urlencoded"
|
||||
)
|
||||
assert "authorization" not in request.headers
|
||||
assert parse_qs(request.content.decode()) == {
|
||||
"client_id": [DEVICE_AUTH_CLIENT_ID],
|
||||
"device_name": ["Avery's laptop"],
|
||||
"platform": ["darwin"],
|
||||
"architecture": ["arm64"],
|
||||
"client_version": ["4.0.0"],
|
||||
}
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"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": 600,
|
||||
"interval": 5,
|
||||
},
|
||||
)
|
||||
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(handler),
|
||||
) as client:
|
||||
result = await client.create_device_authorization(
|
||||
DeviceMetadata(
|
||||
device_name="Avery's laptop",
|
||||
platform="darwin",
|
||||
architecture="arm64",
|
||||
client_version="4.0.0",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.user_code == "BCDF-GHJK"
|
||||
assert result.interval == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
["authorization_pending", "slow_down", "access_denied", "expired_token"],
|
||||
)
|
||||
async def test_device_token_exchange_returns_expected_poll_errors(error: str) -> None:
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
assert parse_qs(request.content.decode()) == {
|
||||
"grant_type": [DEVICE_AUTH_GRANT_TYPE],
|
||||
"client_id": [DEVICE_AUTH_CLIENT_ID],
|
||||
"device_code": ["device-secret"],
|
||||
}
|
||||
return httpx2.Response(400, json={"error": error})
|
||||
|
||||
async with HorizonClient(transport=mock_transport(handler)) as client:
|
||||
result = await client.exchange_device_authorization("device-secret")
|
||||
|
||||
assert result.error == error
|
||||
assert result.access_token is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_token", ["", " "])
|
||||
async def test_device_token_exchange_rejects_empty_access_tokens(
|
||||
access_token: str,
|
||||
) -> None:
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(
|
||||
lambda request: httpx2.Response(
|
||||
200,
|
||||
json={"access_token": access_token, "token_type": "Bearer"},
|
||||
)
|
||||
)
|
||||
) as client:
|
||||
with pytest.raises(HorizonResponseError):
|
||||
await client.exchange_device_authorization("device-secret")
|
||||
|
||||
|
||||
async def test_device_token_exchange_keeps_the_api_key_secret() -> None:
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(
|
||||
lambda request: httpx2.Response(
|
||||
200,
|
||||
json={"access_token": "fmcp_secret", "token_type": "Bearer"},
|
||||
)
|
||||
)
|
||||
) as client:
|
||||
result = await client.exchange_device_authorization("device-secret")
|
||||
|
||||
assert isinstance(result.access_token, SecretStr)
|
||||
assert result.access_token.get_secret_value() == "fmcp_secret"
|
||||
assert "fmcp_secret" not in repr(result)
|
||||
|
||||
|
||||
async def test_authenticated_routes_use_the_current_key_and_paginate() -> None:
|
||||
cursors: list[str | None] = []
|
||||
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
assert request.headers["authorization"] == "Bearer fmcp_secret"
|
||||
if request.url.path == "/api/v0/me":
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"user": {
|
||||
"id": "user-id",
|
||||
"email": "avery@example.com",
|
||||
"name": "Avery",
|
||||
"workosUserId": "workos-id",
|
||||
"createdAt": "2026-08-08T00:00:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert request.url.path == "/api/v0/me/organizations"
|
||||
cursor = request.url.params.get("cursor")
|
||||
cursors.append(cursor)
|
||||
if cursor is None:
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [{"id": "org-1", "name": "First", "slug": "first"}],
|
||||
"meta": {"nextCursor": "next-page", "limit": 100},
|
||||
},
|
||||
)
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [{"id": "org-2", "name": "Second", "slug": "second"}],
|
||||
"meta": {"nextCursor": None, "limit": 100},
|
||||
},
|
||||
)
|
||||
|
||||
async with HorizonClient(
|
||||
api_key="fmcp_secret",
|
||||
transport=mock_transport(handler),
|
||||
) as client:
|
||||
user = await client.get_current_user()
|
||||
organizations = await client.list_organizations()
|
||||
|
||||
assert user.email == "avery@example.com"
|
||||
assert [organization.slug for organization in organizations] == ["first", "second"]
|
||||
assert cursors == [None, "next-page"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 1, 3])
|
||||
async def test_organization_memberships_preserve_zero_one_and_many(count: int) -> None:
|
||||
organizations = [
|
||||
{"id": f"org-{index}", "name": f"Org {index}", "slug": f"org-{index}"}
|
||||
for index in range(count)
|
||||
]
|
||||
async with HorizonClient(
|
||||
api_key="fmcp_secret",
|
||||
transport=mock_transport(
|
||||
lambda request: httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"items": organizations,
|
||||
"meta": {"nextCursor": None, "limit": 100},
|
||||
},
|
||||
)
|
||||
),
|
||||
) as client:
|
||||
result = await client.list_organizations()
|
||||
|
||||
assert len(result) == count
|
||||
|
||||
|
||||
async def test_revoke_uses_the_current_authenticated_key() -> None:
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
assert request.method == "DELETE"
|
||||
assert request.url.path == "/api/v0/me/api-key"
|
||||
assert request.headers["authorization"] == "Bearer fmcp_current"
|
||||
return httpx2.Response(204)
|
||||
|
||||
async with HorizonClient(
|
||||
api_key="fmcp_current",
|
||||
transport=mock_transport(handler),
|
||||
) as client:
|
||||
await client.revoke_current_api_key()
|
||||
|
||||
|
||||
async def test_protected_routes_require_a_credential() -> None:
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(lambda request: httpx2.Response(200))
|
||||
) as client:
|
||||
with pytest.raises(HorizonUnauthorizedError):
|
||||
await client.get_current_user()
|
||||
|
||||
|
||||
async def test_protected_routes_report_a_rejected_credential() -> None:
|
||||
async with HorizonClient(
|
||||
api_key="fmcp_invalid",
|
||||
transport=mock_transport(lambda request: httpx2.Response(401)),
|
||||
) as client:
|
||||
with pytest.raises(HorizonUnauthorizedError):
|
||||
await client.get_current_user()
|
||||
|
||||
|
||||
async def test_public_routes_do_not_report_a_missing_credential() -> None:
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(lambda request: httpx2.Response(401))
|
||||
) as client:
|
||||
with pytest.raises(HorizonResponseError):
|
||||
await client.create_device_authorization()
|
||||
|
||||
|
||||
async def test_invalid_responses_do_not_include_response_bodies() -> None:
|
||||
secret_body = "fmcp_response_secret"
|
||||
async with HorizonClient(
|
||||
transport=mock_transport(lambda request: httpx2.Response(500, text=secret_body))
|
||||
) as client:
|
||||
with pytest.raises(HorizonResponseError) as exc_info:
|
||||
await client.create_device_authorization()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert secret_body not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"ftp://horizon.prefect.io",
|
||||
"https://user@example.com",
|
||||
"https://horizon.prefect.io/path",
|
||||
"https://horizon.prefect.io?query=value",
|
||||
"https://horizon.prefect.io:abc",
|
||||
"https://horizon.prefect.io:99999",
|
||||
],
|
||||
)
|
||||
def test_api_origin_rejects_values_that_are_not_origins(value: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
normalize_api_origin(value)
|
||||
|
||||
|
||||
def test_api_origin_normalizes_one_trailing_slash() -> None:
|
||||
assert (
|
||||
normalize_api_origin("https://horizon.prefect.io/")
|
||||
== "https://horizon.prefect.io"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue