feat(10-02): Google Calendar OAuth endpoints and per-tenant calendar tool
- 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
This commit is contained in:
205
tests/unit/test_calendar_auth.py
Normal file
205
tests/unit/test_calendar_auth.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
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
|
||||
423
tests/unit/test_calendar_lookup.py
Normal file
423
tests/unit/test_calendar_lookup.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
Unit tests for the per-tenant OAuth calendar_lookup tool.
|
||||
|
||||
Tests:
|
||||
- Returns "not configured" message when no tenant_id provided
|
||||
- Returns "not connected" message when no ChannelConnection exists for tenant
|
||||
- action="list" calls Google Calendar API and returns formatted event list
|
||||
- action="check_availability" returns free/busy summary
|
||||
- action="create" creates an event and returns confirmation
|
||||
- Token refresh write-back: updated credentials written to DB
|
||||
- All responses are natural language strings (no raw JSON)
|
||||
- API errors return human-readable messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TENANT_ID = str(uuid.uuid4())
|
||||
_DATE = "2026-03-26"
|
||||
|
||||
# Fake encrypted token JSON stored in channel_connections.config
|
||||
_FAKE_ENCRYPTED_TOKEN = "gAAAAAB..."
|
||||
|
||||
# Decrypted token dict (as would come from Google OAuth)
|
||||
_FAKE_TOKEN_DICT = {
|
||||
"token": "ya29.test_access_token",
|
||||
"refresh_token": "1//test_refresh_token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "test-client-id",
|
||||
"client_secret": "test-client-secret",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar"],
|
||||
}
|
||||
|
||||
# Sample Google Calendar events response
|
||||
_FAKE_EVENTS = {
|
||||
"items": [
|
||||
{
|
||||
"summary": "Team Standup",
|
||||
"start": {"dateTime": "2026-03-26T09:00:00+00:00"},
|
||||
"end": {"dateTime": "2026-03-26T09:30:00+00:00"},
|
||||
},
|
||||
{
|
||||
"summary": "Sprint Planning",
|
||||
"start": {"dateTime": "2026-03-26T14:00:00+00:00"},
|
||||
"end": {"dateTime": "2026-03-26T15:00:00+00:00"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Common patch targets
|
||||
_PATCH_ENC = "orchestrator.tools.builtins.calendar_lookup.KeyEncryptionService"
|
||||
_PATCH_CREDS = "orchestrator.tools.builtins.calendar_lookup.google_credentials_from_token"
|
||||
_PATCH_BUILD = "orchestrator.tools.builtins.calendar_lookup.build"
|
||||
_PATCH_SETTINGS = "orchestrator.tools.builtins.calendar_lookup.settings"
|
||||
|
||||
|
||||
def _make_mock_session(conn_config: dict | None = None):
|
||||
"""Build a mock AsyncSession that returns a ChannelConnection or None."""
|
||||
session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
if conn_config is not None:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.id = uuid.uuid4()
|
||||
mock_conn.config = conn_config
|
||||
mock_result.scalar_one_or_none.return_value = mock_conn
|
||||
else:
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
session.execute.return_value = mock_result
|
||||
session.commit = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def _make_enc_mock():
|
||||
"""Create a mock KeyEncryptionService with decrypt returning the fake token JSON."""
|
||||
import json
|
||||
mock_enc = MagicMock()
|
||||
mock_enc.decrypt.return_value = json.dumps(_FAKE_TOKEN_DICT)
|
||||
mock_enc.encrypt.return_value = "new_encrypted_token"
|
||||
return mock_enc
|
||||
|
||||
|
||||
def _make_mock_settings():
|
||||
"""Create mock settings with encryption key configured."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.platform_encryption_key = "test-key"
|
||||
mock_settings.platform_encryption_key_previous = ""
|
||||
return mock_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No tenant_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_no_tenant_id_returns_message():
|
||||
"""calendar_lookup without tenant_id returns a helpful error message."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
result = await calendar_lookup(date=_DATE)
|
||||
assert "tenant" in result.lower() or "not available" in result.lower()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Not connected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_not_connected_returns_message():
|
||||
"""calendar_lookup with no ChannelConnection returns 'not connected' message."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config=None)
|
||||
|
||||
# Pass _session directly to bypass DB session creation
|
||||
result = await calendar_lookup(date=_DATE, tenant_id=_TENANT_ID, _session=mock_session)
|
||||
|
||||
assert "not connected" in result.lower() or "connect" in result.lower()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action="list"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_list_returns_formatted_events():
|
||||
"""
|
||||
action="list" returns a natural-language event list.
|
||||
No raw JSON — results are human-readable strings.
|
||||
"""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.token = "ya29.test_access_token"
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_events_list = MagicMock()
|
||||
mock_events_list.execute.return_value = _FAKE_EVENTS
|
||||
mock_service.events.return_value.list.return_value = mock_events_list
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="list",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Team Standup" in result
|
||||
assert "Sprint Planning" in result
|
||||
# No raw JSON
|
||||
assert "{" not in result or "items" not in result
|
||||
|
||||
|
||||
async def test_calendar_lookup_list_no_events():
|
||||
"""action="list" with no events returns a 'no events' message."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="list",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert "no event" in result.lower() or "free" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action="check_availability"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_check_availability_with_events():
|
||||
"""action="check_availability" returns busy slot summary when events exist."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.events.return_value.list.return_value.execute.return_value = _FAKE_EVENTS
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="check_availability",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "busy" in result.lower() or "slot" in result.lower() or "standup" in result.lower()
|
||||
|
||||
|
||||
async def test_calendar_lookup_check_availability_free_day():
|
||||
"""action="check_availability" with no events returns 'entire day is free'."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="check_availability",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert "free" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action="create"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_create_event():
|
||||
"""action="create" inserts an event and returns confirmation."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
created_event = {
|
||||
"id": "abc123",
|
||||
"summary": "Product Demo",
|
||||
"start": {"dateTime": "2026-03-26T10:00:00+00:00"},
|
||||
"end": {"dateTime": "2026-03-26T11:00:00+00:00"},
|
||||
}
|
||||
mock_service = MagicMock()
|
||||
mock_service.events.return_value.insert.return_value.execute.return_value = created_event
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="create",
|
||||
event_summary="Product Demo",
|
||||
event_start="2026-03-26T10:00:00+00:00",
|
||||
event_end="2026-03-26T11:00:00+00:00",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "created" in result.lower() or "product demo" in result.lower()
|
||||
|
||||
|
||||
async def test_calendar_lookup_create_missing_fields():
|
||||
"""action="create" without event_summary returns an error message."""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = _make_enc_mock()
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
result = await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="create",
|
||||
# No event_summary, event_start, event_end
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "error" in result.lower() or "required" in result.lower() or "missing" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token refresh write-back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_calendar_lookup_token_refresh_writeback():
|
||||
"""
|
||||
When credentials.token changes after an API call (refresh occurred),
|
||||
the updated token should be encrypted and written back to channel_connections.
|
||||
"""
|
||||
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||
|
||||
conn_id = uuid.uuid4()
|
||||
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||
# Get the mock connection to track updates
|
||||
mock_conn = mock_session.execute.return_value.scalar_one_or_none.return_value
|
||||
mock_conn.id = conn_id
|
||||
mock_conn.config = {"token": _FAKE_ENCRYPTED_TOKEN}
|
||||
|
||||
# Credentials that change token after API call (simulating refresh)
|
||||
original_token = "ya29.original_token"
|
||||
refreshed_token = "ya29.refreshed_token"
|
||||
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.token = original_token
|
||||
mock_creds.refresh_token = "1//refresh_token"
|
||||
mock_creds.expired = False
|
||||
mock_creds.valid = True
|
||||
|
||||
def simulate_api_call_that_refreshes():
|
||||
"""Simulate the side effect of token refresh during API call."""
|
||||
mock_creds.token = refreshed_token
|
||||
return {"items": []}
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.events.return_value.list.return_value.execute.side_effect = simulate_api_call_that_refreshes
|
||||
|
||||
mock_enc = _make_enc_mock()
|
||||
|
||||
with (
|
||||
patch(_PATCH_ENC) as mock_enc_cls,
|
||||
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||
patch(_PATCH_BUILD) as mock_build,
|
||||
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||
):
|
||||
mock_enc_cls.return_value = mock_enc
|
||||
mock_creds_fn.return_value = mock_creds
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
await calendar_lookup(
|
||||
date=_DATE,
|
||||
action="list",
|
||||
tenant_id=_TENANT_ID,
|
||||
_session=mock_session,
|
||||
)
|
||||
|
||||
# encrypt should have been called for write-back
|
||||
mock_enc.encrypt.assert_called()
|
||||
# session.commit should have been called to persist the updated token
|
||||
mock_session.commit.assert_called()
|
||||
Reference in New Issue
Block a user