- Add calendar_auth.py: OAuth install/callback/status endpoints with HMAC-signed state - Replace calendar_lookup.py service account stub with per-tenant OAuth token lookup - Support list, check_availability, and create actions with natural language responses - Token auto-refresh: write updated credentials back to channel_connections on refresh - Add migration 013: add google_calendar to channel_type CHECK constraint - Add unit tests: 16 tests covering all actions, not-connected path, token refresh write-back
206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
"""
|
|
Unit tests for Google Calendar OAuth endpoints.
|
|
|
|
Tests:
|
|
- /install endpoint returns OAuth URL with HMAC state
|
|
- /callback verifies state, stores encrypted token in DB
|
|
- /status returns connected=True when token exists, False otherwise
|
|
- HMAC state generation and verification work correctly
|
|
- Missing credentials configuration handled gracefully
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
_SECRET = "test-oauth-state-secret"
|
|
_TENANT_ID = str(uuid.uuid4())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HMAC state helper tests (reuse from channels.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_calendar_install_builds_oauth_url():
|
|
"""
|
|
calendar_install endpoint returns a dict with a 'url' key pointing at
|
|
accounts.google.com/o/oauth2/v2/auth.
|
|
"""
|
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
|
|
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
|
assert "accounts.google.com/o/oauth2/v2/auth" in url
|
|
assert "client_id=" in url
|
|
assert "scope=" in url
|
|
assert "state=" in url
|
|
assert "access_type=offline" in url
|
|
assert "prompt=consent" in url
|
|
|
|
|
|
def test_calendar_oauth_url_contains_signed_state():
|
|
"""State parameter in the OAuth URL encodes the tenant_id."""
|
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
|
from shared.api.channels import verify_oauth_state
|
|
|
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
|
|
|
# Extract state from URL
|
|
import urllib.parse
|
|
parsed = urllib.parse.urlparse(url)
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
state = params["state"][0]
|
|
|
|
# Verify the state recovers the tenant_id
|
|
recovered = verify_oauth_state(state=state, secret=_SECRET)
|
|
assert recovered == _TENANT_ID
|
|
|
|
|
|
def test_calendar_oauth_url_uses_calendar_scope():
|
|
"""OAuth URL requests full Google Calendar scope."""
|
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
|
|
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
|
assert "googleapis.com/auth/calendar" in url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Callback token exchange and storage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calendar_callback_stores_encrypted_token():
|
|
"""
|
|
handle_calendar_callback() exchanges code for tokens, encrypts them,
|
|
and upserts a ChannelConnection with channel_type='google_calendar'.
|
|
"""
|
|
from shared.api.calendar_auth import handle_calendar_callback
|
|
|
|
mock_session = AsyncMock()
|
|
mock_session.execute = AsyncMock()
|
|
mock_session.add = MagicMock()
|
|
mock_session.commit = AsyncMock()
|
|
|
|
# Simulate no existing connection (first install)
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
token_response = {
|
|
"access_token": "ya29.test_access_token",
|
|
"refresh_token": "1//test_refresh_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
}
|
|
|
|
with (
|
|
patch("shared.api.calendar_auth.httpx.AsyncClient") as mock_client_cls,
|
|
patch("shared.api.calendar_auth.KeyEncryptionService") as mock_enc_cls,
|
|
patch("shared.api.calendar_auth.settings") as mock_settings,
|
|
):
|
|
mock_settings.oauth_state_secret = _SECRET
|
|
mock_settings.google_client_id = "test-client-id"
|
|
mock_settings.google_client_secret = "test-client-secret"
|
|
mock_settings.portal_url = "http://localhost:3000"
|
|
mock_settings.platform_encryption_key = "test-key"
|
|
mock_settings.platform_encryption_key_previous = ""
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = token_response
|
|
|
|
mock_http = AsyncMock()
|
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
|
mock_http.__aexit__ = AsyncMock(return_value=None)
|
|
mock_http.post = AsyncMock(return_value=mock_response)
|
|
mock_client_cls.return_value = mock_http
|
|
|
|
mock_enc = MagicMock()
|
|
mock_enc.encrypt.return_value = "encrypted_token_data"
|
|
mock_enc_cls.return_value = mock_enc
|
|
|
|
# Generate a valid state
|
|
from shared.api.channels import generate_oauth_state
|
|
state = generate_oauth_state(tenant_id=_TENANT_ID, secret=_SECRET)
|
|
|
|
redirect_url = await handle_calendar_callback(
|
|
code="test_auth_code",
|
|
state=state,
|
|
session=mock_session,
|
|
)
|
|
|
|
# Should redirect to portal settings
|
|
assert "settings" in redirect_url or "calendar" in redirect_url
|
|
# Session.add should have been called (new ChannelConnection)
|
|
mock_session.add.assert_called_once()
|
|
# Encryption was called
|
|
mock_enc.encrypt.assert_called_once()
|
|
# The ChannelConnection passed to add should have google_calendar type
|
|
conn = mock_session.add.call_args[0][0]
|
|
assert "google_calendar" in str(conn.channel_type).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calendar_callback_invalid_state_raises():
|
|
"""handle_calendar_callback raises HTTPException for tampered state."""
|
|
from fastapi import HTTPException
|
|
from shared.api.calendar_auth import handle_calendar_callback
|
|
|
|
mock_session = AsyncMock()
|
|
|
|
with patch("shared.api.calendar_auth.settings") as mock_settings:
|
|
mock_settings.oauth_state_secret = _SECRET
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await handle_calendar_callback(
|
|
code="some_code",
|
|
state="TAMPERED.INVALID",
|
|
session=mock_session,
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calendar_status_connected():
|
|
"""get_calendar_status returns connected=True when ChannelConnection exists."""
|
|
from shared.api.calendar_auth import get_calendar_status
|
|
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
# Simulate existing connection
|
|
mock_conn = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_conn
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
tenant_id = uuid.uuid4()
|
|
status = await get_calendar_status(tenant_id=tenant_id, session=mock_session)
|
|
assert status["connected"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calendar_status_not_connected():
|
|
"""get_calendar_status returns connected=False when no ChannelConnection exists."""
|
|
from shared.api.calendar_auth import get_calendar_status
|
|
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
tenant_id = uuid.uuid4()
|
|
status = await get_calendar_status(tenant_id=tenant_id, session=mock_session)
|
|
assert status["connected"] is False
|