Skip to main content
Glama

Hostaway MCP Server

test_auth_api.py15.4 kB
"""Integration tests for Hostaway authentication API. Tests OAuth 2.0 token exchange, authentication flow, and automatic token refresh. Following TDD: These tests should FAIL until implementation is complete. """ import json from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from fastapi.testclient import TestClient from src.api.main import app from src.models.auth import AccessToken, TokenRefreshResponse from src.mcp.auth import TokenManager from src.mcp.config import HostawayConfig @pytest.fixture def test_config(monkeypatch: pytest.MonkeyPatch) -> HostawayConfig: """Create test configuration.""" # Set environment variables for pydantic-settings monkeypatch.setenv("HOSTAWAY_ACCOUNT_ID", "test_account_123") monkeypatch.setenv("HOSTAWAY_SECRET_KEY", "test_secret_key_456") monkeypatch.setenv("HOSTAWAY_API_BASE_URL", "https://api.hostaway.com/v1") monkeypatch.setenv("HOSTAWAY_RATE_LIMIT_IP", "15") monkeypatch.setenv("HOSTAWAY_RATE_LIMIT_ACCOUNT", "20") monkeypatch.setenv("HOSTAWAY_MAX_CONCURRENT_REQUESTS", "5") monkeypatch.setenv("HOSTAWAY_TOKEN_REFRESH_THRESHOLD_DAYS", "7") return HostawayConfig() # type: ignore[call-arg] @pytest.fixture def mock_token_response() -> dict[str, str | int]: """Create mock Hostaway token response.""" return { "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.mock_token_data", "token_type": "Bearer", "expires_in": 63072000, # 24 months in seconds "scope": "general", } @pytest.fixture def client() -> TestClient: """Create test client for FastAPI app.""" return TestClient(app) # T030: Contract test for OAuth token endpoint class TestOAuthTokenEndpoint: """Test OAuth 2.0 /accessTokens endpoint contract.""" @pytest.mark.asyncio async def test_token_exchange_success( self, test_config: HostawayConfig, mock_token_response: dict[str, str | int] ) -> None: """Test successful token exchange with client_credentials grant. Verifies: - POST /accessTokens accepts form-encoded credentials - Returns access_token, expires_in, token_type - Token type is 'Bearer' - expires_in is positive integer """ # Mock httpx client response mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 200 mock_response.json.return_value = mock_token_response mock_response.raise_for_status = MagicMock() # raise_for_status is also sync mock_client.post = AsyncMock(return_value=mock_response) # Create TokenManager with mock client token_manager = TokenManager(config=test_config, client=mock_client) # Exchange credentials for token token = await token_manager.get_token() # Verify token structure assert isinstance(token, AccessToken) assert token.access_token == mock_token_response["access_token"] assert token.token_type == "Bearer" assert token.expires_in == mock_token_response["expires_in"] assert token.scope == "general" # Verify request was made correctly mock_client.post.assert_called_once() call_args = mock_client.post.call_args assert call_args[0][0] == "/accessTokens" assert call_args[1]["headers"]["Content-Type"] == "application/x-www-form-urlencoded" @pytest.mark.asyncio async def test_token_exchange_invalid_credentials( self, test_config: HostawayConfig ) -> None: """Test token exchange with invalid credentials returns 401. Verifies: - Invalid client_id/client_secret raises HTTPStatusError - Error status code is 401 - Error response contains 'invalid_client' """ # Mock httpx client with 401 error mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods mock_response.status_code = 401 mock_response.json.return_value = { "error": "invalid_client", "error_description": "Client authentication failed", "status": 401, } mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "401 Unauthorized", request=MagicMock(), response=mock_response, ) mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) # Attempt token exchange should raise ValueError (transformed from 401) with pytest.raises(ValueError, match="Invalid Hostaway credentials"): await token_manager.get_token() @pytest.mark.asyncio async def test_token_exchange_missing_parameters( self, test_config: HostawayConfig ) -> None: """Test token exchange with missing required parameters returns 400. Verifies: - Missing client_id or client_secret returns 400 - Error response indicates missing parameter """ # Mock httpx client with 400 error mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 400 mock_response.json.return_value = { "error": "invalid_request", "error_description": "Missing required parameter: client_id", "status": 400, } mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "400 Bad Request", request=MagicMock(), response=mock_response, ) mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) with pytest.raises(httpx.HTTPStatusError) as exc_info: await token_manager.get_token() assert exc_info.value.response.status_code == 400 # T031: Integration test for authentication flow class TestAuthenticationFlow: """Test complete authentication flow with valid/invalid credentials.""" @pytest.mark.asyncio async def test_authentication_flow_valid_credentials( self, test_config: HostawayConfig, mock_token_response: dict[str, str | int] ) -> None: """Test complete authentication flow with valid credentials. Flow: 1. Provide valid account_id and secret_key 2. TokenManager exchanges credentials for token 3. Token is stored and accessible 4. Subsequent requests use cached token """ mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 200 mock_response.json.return_value = mock_token_response mock_response.raise_for_status = MagicMock() mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) # First token request token1 = await token_manager.get_token() assert token1.access_token == mock_token_response["access_token"] # Second request should use cached token (no additional API call) token2 = await token_manager.get_token() assert token2.access_token == token1.access_token # Verify only one API call was made assert mock_client.post.call_count == 1 @pytest.mark.asyncio async def test_authentication_flow_invalid_credentials( self, test_config: HostawayConfig ) -> None: """Test authentication flow with invalid credentials. Verifies: - Invalid credentials result in HTTPStatusError - Error is 401 Unauthorized - Token is not stored """ mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 401 mock_response.json.return_value = { "error": "invalid_client", "error_description": "Client authentication failed", } mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "401 Unauthorized", request=MagicMock(), response=mock_response, ) mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) # Authentication should fail with ValueError (transformed from 401) with pytest.raises(ValueError, match="Invalid Hostaway credentials"): await token_manager.get_token() # Token should not be stored assert token_manager._token is None @pytest.mark.asyncio async def test_authentication_flow_rate_limit_exceeded( self, test_config: HostawayConfig ) -> None: """Test authentication flow when rate limit is exceeded. Verifies: - Rate limit error (429) is raised - Error response includes retry_after """ mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 429 mock_response.json.return_value = { "error": "rate_limit_exceeded", "error_description": "Too many requests", "status": 429, "retry_after": 10, } mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "429 Too Many Requests", request=MagicMock(), response=mock_response, ) mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) # Rate limit error is transformed to RuntimeError with pytest.raises(RuntimeError, match="Rate limit exceeded"): await token_manager.get_token() # T032: Integration test for token refresh flow class TestTokenRefreshFlow: """Test automatic token refresh when token is near expiration.""" @pytest.mark.asyncio async def test_token_auto_refresh_when_expiring_soon( self, test_config: HostawayConfig, mock_token_response: dict[str, str | int] ) -> None: """Test automatic token refresh when expiring within threshold. Verifies: - Token is refreshed when days_until_expiration <= 7 - New token replaces old token - Refresh is triggered automatically by get_token() """ mock_client = AsyncMock(spec=httpx.AsyncClient) # First response: token expiring in 5 days expiring_token_response = mock_token_response.copy() expiring_token_response["expires_in"] = 5 * 24 * 60 * 60 # 5 days in seconds # Second response: fresh token with 24 months fresh_token_response = mock_token_response.copy() fresh_token_response["access_token"] = "fresh_token_xyz_20_chars_minimum" mock_response1 = MagicMock() # Use MagicMock for sync methods like json() mock_response1.status_code = 200 mock_response1.json.return_value = expiring_token_response mock_response1.raise_for_status = MagicMock() mock_response2 = MagicMock() # Use MagicMock for sync methods like json() mock_response2.status_code = 200 mock_response2.json.return_value = fresh_token_response mock_response2.raise_for_status = MagicMock() mock_client.post = AsyncMock(side_effect=[mock_response1, mock_response2]) token_manager = TokenManager(config=test_config, client=mock_client) # First call: get expiring token token1 = await token_manager.get_token() assert token1.days_until_expiration <= 7 # Second call: should auto-refresh token2 = await token_manager.get_token() assert token2.access_token == "fresh_token_xyz_20_chars_minimum" assert token2.access_token != token1.access_token # Verify two API calls were made assert mock_client.post.call_count == 2 @pytest.mark.asyncio async def test_token_refresh_manual_invalidation( self, test_config: HostawayConfig, mock_token_response: dict[str, str | int] ) -> None: """Test manual token invalidation triggers refresh. Verifies: - invalidate_token() clears cached token - Next get_token() call fetches new token """ mock_client = AsyncMock(spec=httpx.AsyncClient) # Two different tokens (must be 20+ chars for validation) token_response1 = mock_token_response.copy() token_response1["access_token"] = "token_1_with_20_chars_min" token_response2 = mock_token_response.copy() token_response2["access_token"] = "token_2_with_20_chars_min" mock_response1 = MagicMock() # Use MagicMock for sync methods like json() mock_response1.status_code = 200 mock_response1.json.return_value = token_response1 mock_response1.raise_for_status = MagicMock() mock_response2 = MagicMock() # Use MagicMock for sync methods like json() mock_response2.status_code = 200 mock_response2.json.return_value = token_response2 mock_response2.raise_for_status = MagicMock() mock_client.post = AsyncMock(side_effect=[mock_response1, mock_response2]) token_manager = TokenManager(config=test_config, client=mock_client) # Get first token token1 = await token_manager.get_token() assert token1.access_token == "token_1_with_20_chars_min" # Invalidate token await token_manager.invalidate_token() # Get token again - should fetch new token token2 = await token_manager.get_token() assert token2.access_token == "token_2_with_20_chars_min" # Verify two API calls assert mock_client.post.call_count == 2 @pytest.mark.asyncio async def test_token_no_refresh_when_valid( self, test_config: HostawayConfig, mock_token_response: dict[str, str | int] ) -> None: """Test token is NOT refreshed when still valid. Verifies: - Token with >7 days until expiration is not refreshed - Cached token is reused """ mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() # Use MagicMock for sync methods like json() mock_response.status_code = 200 mock_response.json.return_value = mock_token_response mock_response.raise_for_status = MagicMock() mock_client.post = AsyncMock(return_value=mock_response) token_manager = TokenManager(config=test_config, client=mock_client) # Get token token1 = await token_manager.get_token() # Get token again (should use cache) token2 = await token_manager.get_token() # Tokens should be identical (cached) assert token1.access_token == token2.access_token assert token1.issued_at == token2.issued_at # Only one API call should have been made assert mock_client.post.call_count == 1

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/darrentmorgan/hostaway-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server