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:
2026-03-26 09:07:37 -06:00
parent e8d3e8a108
commit 08572fcc40
5 changed files with 1318 additions and 62 deletions

View File

@@ -0,0 +1,52 @@
"""Add google_calendar to channel_type CHECK constraint
Revision ID: 013
Revises: 012
Create Date: 2026-03-26
Adds 'google_calendar' to the valid channel types in channel_connections.
This enables per-tenant Google Calendar OAuth token storage alongside
existing Slack/WhatsApp/web connections.
Steps:
1. Drop old CHECK constraint on channel_connections.channel_type
2. Re-create it with the updated list including 'google_calendar'
"""
from __future__ import annotations
from alembic import op
# Alembic revision identifiers
revision: str = "013"
down_revision: str | None = "012"
branch_labels = None
depends_on = None
# All valid channel types including 'google_calendar'
_CHANNEL_TYPES = (
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal", "web", "google_calendar"
)
def upgrade() -> None:
# Drop the existing CHECK constraint (added in 008_web_chat.py as chk_channel_type)
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
# Re-create with the updated list
op.execute(
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
f"CHECK (channel_type IN {tuple(_CHANNEL_TYPES)})"
)
def downgrade() -> None:
# Restore 008's constraint (without google_calendar)
_PREV_TYPES = (
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal", "web"
)
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
op.execute(
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
f"CHECK (channel_type IN {tuple(_PREV_TYPES)})"
)

View File

@@ -1,108 +1,302 @@
"""
Built-in tool: calendar_lookup
Reads calendar events from Google Calendar for a given date.
Reads and creates Google Calendar events using per-tenant OAuth tokens.
Authentication options (in priority order):
1. GOOGLE_SERVICE_ACCOUNT_KEY env var — JSON key for service account impersonation
2. Per-tenant OAuth (future: Phase 3 portal) — not yet implemented
3. Graceful degradation: returns informative message if not configured
Authentication:
Tokens are stored per-tenant in channel_connections (channel_type='google_calendar').
The tenant admin must complete the OAuth flow via /api/portal/calendar/install first.
If no token is found, returns an informative message asking admin to connect.
This tool is read-only (requires_confirmation=False in registry).
Actions:
- list: List events for the given date (default)
- check_availability: Return free/busy summary for the given date
- create: Create a new calendar event
Token auto-refresh:
google.oauth2.credentials.Credentials auto-refreshes expired access tokens
using the stored refresh_token. After each API call, if credentials.token
changed (refresh occurred), the updated token is encrypted and written back
to channel_connections so subsequent calls don't re-trigger refresh.
All results are formatted as natural language strings — no raw JSON exposed.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from datetime import datetime, timezone
import uuid
from typing import Any
# Module-level imports for patchability in tests.
# google-auth and googleapiclient are optional dependencies — import errors handled
# gracefully in the functions that use them.
try:
from googleapiclient.discovery import build # type: ignore[import-untyped]
except ImportError:
build = None # type: ignore[assignment]
from shared.config import settings
from shared.crypto import KeyEncryptionService
logger = logging.getLogger(__name__)
# Google Calendar API scope (must match what was requested during OAuth)
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
def google_credentials_from_token(token_dict: dict[str, Any]) -> Any:
"""
Build a google.oauth2.credentials.Credentials object from a stored token dict.
The token dict is the JSON structure written by calendar_auth.py during OAuth:
{
"token": "ya29.access_token",
"refresh_token": "1//refresh_token",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "...",
"client_secret": "...",
"scopes": ["https://www.googleapis.com/auth/calendar"]
}
Args:
token_dict: Parsed token dictionary.
Returns:
google.oauth2.credentials.Credentials instance.
Raises:
ImportError: If google-auth is not installed.
"""
from google.oauth2.credentials import Credentials # type: ignore[import-untyped]
return Credentials(
token=token_dict.get("token"),
refresh_token=token_dict.get("refresh_token"),
token_uri=token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
client_id=token_dict.get("client_id"),
client_secret=token_dict.get("client_secret"),
scopes=token_dict.get("scopes", [_CALENDAR_SCOPE]),
)
async def calendar_lookup(
date: str,
action: str = "list",
event_summary: str | None = None,
event_start: str | None = None,
event_end: str | None = None,
calendar_id: str = "primary",
tenant_id: str | None = None,
_session: Any = None, # Injected in tests; production uses DB session from task context
**kwargs: object,
) -> str:
"""
Look up calendar events for a specific date.
Look up, check availability, or create Google Calendar events for a specific date.
Args:
date: Date in YYYY-MM-DD format.
calendar_id: Google Calendar ID. Defaults to 'primary'.
date: Date in YYYY-MM-DD format (required).
action: One of "list", "check_availability", "create". Default: "list".
event_summary: Event title (required for action="create").
event_start: ISO 8601 datetime with timezone (required for action="create").
event_end: ISO 8601 datetime with timezone (required for action="create").
calendar_id: Google Calendar ID. Defaults to 'primary'.
tenant_id: Konstruct tenant UUID string. Required for token lookup.
_session: Injected AsyncSession (for testing). Production passes None.
Returns:
Formatted string listing events for the given date,
or an informative message if Google Calendar is not configured.
Natural language string describing the result.
"""
service_account_key_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY", "")
if not service_account_key_json:
return (
"Calendar lookup is not configured. "
"Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to enable calendar access."
)
# Guard: tenant_id is required to look up per-tenant OAuth token
if not tenant_id:
return "Calendar not available: missing tenant context."
# Get DB session
session = _session
if session is None:
# Production: obtain a session from the DB pool
# Import here to avoid circular imports at module load time
try:
from shared.db import async_session_factory
session = async_session_factory()
# Note: caller is responsible for closing the session
# In practice, the orchestrator task context manages session lifecycle
except Exception:
logger.exception("Failed to create DB session for calendar_lookup")
return "Calendar lookup failed: unable to connect to the database."
try:
import asyncio
tenant_uuid = uuid.UUID(tenant_id)
except ValueError:
return f"Calendar lookup failed: invalid tenant ID '{tenant_id}'."
result = await asyncio.get_event_loop().run_in_executor(
None,
_fetch_calendar_events_sync,
service_account_key_json,
calendar_id,
date,
# Load per-tenant OAuth token from channel_connections
try:
from sqlalchemy import select
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
result = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_uuid,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
return result
conn = result.scalar_one_or_none()
except Exception:
logger.exception("Calendar lookup failed for date=%s calendar=%s", date, calendar_id)
logger.exception("DB error loading calendar connection for tenant=%s", tenant_id)
return "Calendar lookup failed: database error loading calendar connection."
if conn is None:
return (
"Google Calendar is not connected for this tenant. "
"Ask an admin to connect it in Settings."
)
# Decrypt token
encrypted_token = conn.config.get("token", "")
if not encrypted_token:
return "Calendar lookup failed: no token found in connection config."
try:
if not settings.platform_encryption_key:
return "Calendar lookup failed: encryption key not configured."
enc_svc = KeyEncryptionService(
primary_key=settings.platform_encryption_key,
previous_key=settings.platform_encryption_key_previous,
)
token_json: str = enc_svc.decrypt(encrypted_token)
token_dict: dict[str, Any] = json.loads(token_json)
except Exception:
logger.exception("Failed to decrypt calendar token for tenant=%s", tenant_id)
return "Calendar lookup failed: unable to decrypt stored credentials."
# Build Google credentials
try:
creds = google_credentials_from_token(token_dict)
except ImportError:
return (
"Google Calendar library not installed. "
"Run: uv add google-api-python-client google-auth"
)
except Exception:
logger.exception("Failed to build Google credentials for tenant=%s", tenant_id)
return "Calendar lookup failed: invalid stored credentials."
# Record the token before the API call to detect refresh
token_before = creds.token
# Execute the API call in a thread executor (blocking SDK)
try:
result_str = await asyncio.get_event_loop().run_in_executor(
None,
_execute_calendar_action,
creds,
action,
date,
calendar_id,
event_summary,
event_start,
event_end,
)
except Exception:
logger.exception("Calendar API call failed for tenant=%s date=%s action=%s", tenant_id, date, action)
return f"Calendar lookup failed for {date}. Please try again."
# Token refresh write-back: if token changed after the API call, persist the update
if creds.token and creds.token != token_before:
try:
new_token_dict = {
"token": creds.token,
"refresh_token": creds.refresh_token or token_dict.get("refresh_token", ""),
"token_uri": token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
"client_id": token_dict.get("client_id", ""),
"client_secret": token_dict.get("client_secret", ""),
"scopes": token_dict.get("scopes", [_CALENDAR_SCOPE]),
}
new_encrypted = enc_svc.encrypt(json.dumps(new_token_dict))
conn.config = {"token": new_encrypted}
await session.commit()
logger.debug("Calendar token refreshed and written back for tenant=%s", tenant_id)
except Exception:
logger.exception("Failed to write back refreshed calendar token for tenant=%s", tenant_id)
# Non-fatal: the API call succeeded, just log the refresh failure
def _fetch_calendar_events_sync(
service_account_key_json: str,
calendar_id: str,
return result_str
def _execute_calendar_action(
creds: Any,
action: str,
date: str,
calendar_id: str,
event_summary: str | None,
event_start: str | None,
event_end: str | None,
) -> str:
"""
Synchronous implementation — runs in thread executor to avoid blocking event loop.
Synchronous calendar action — runs in thread executor to avoid blocking.
Uses google-api-python-client with service account credentials.
Args:
creds: Google Credentials object.
action: One of "list", "check_availability", "create".
date: Date in YYYY-MM-DD format.
calendar_id: Google Calendar ID (default "primary").
event_summary: Title for create action.
event_start: ISO 8601 start for create action.
event_end: ISO 8601 end for create action.
Returns:
Natural language result string.
"""
try:
from google.oauth2 import service_account
from googleapiclient.discovery import build
except ImportError:
if build is None:
return (
"Google Calendar library not installed. "
"Run: uv add google-api-python-client google-auth"
)
try:
key_data = json.loads(service_account_key_json)
except json.JSONDecodeError:
return "Invalid GOOGLE_SERVICE_ACCOUNT_KEY: not valid JSON."
try:
credentials = service_account.Credentials.from_service_account_info(
key_data,
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
)
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
except Exception as exc:
return f"Failed to create Google credentials: {exc}"
logger.warning("Failed to build Google Calendar service: %s", exc)
return f"Calendar service error: {exc}"
# Parse the date and create RFC3339 time boundaries for the day
if action == "create":
return _action_create(service, calendar_id, event_summary, event_start, event_end)
elif action == "check_availability":
return _action_check_availability(service, calendar_id, date)
else:
# Default: "list"
return _action_list(service, calendar_id, date)
def _time_boundaries(date: str) -> tuple[str, str]:
"""Return (time_min, time_max) RFC3339 strings for the full given day (UTC)."""
return f"{date}T00:00:00Z", f"{date}T23:59:59Z"
def _format_event_time(event: dict[str, Any]) -> str:
"""Extract and format the start time of a calendar event."""
start = event.get("start", {})
raw = start.get("dateTime") or start.get("date") or "Unknown time"
# Trim the timezone part for readability if full datetime
if "T" in raw:
try:
# e.g. "2026-03-26T09:00:00+00:00" → "09:00"
time_part = raw.split("T")[1][:5]
return time_part
except IndexError:
return raw
return raw
def _action_list(service: Any, calendar_id: str, date: str) -> str:
"""List calendar events for the given date."""
time_min, time_max = _time_boundaries(date)
try:
date_obj = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
return f"Invalid date format: {date!r}. Expected YYYY-MM-DD."
time_min = date_obj.strftime("%Y-%m-%dT00:00:00Z")
time_max = date_obj.strftime("%Y-%m-%dT23:59:59Z")
try:
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
events_result = (
service.events()
.list(
@@ -115,17 +309,89 @@ def _fetch_calendar_events_sync(
.execute()
)
except Exception as exc:
logger.warning("Google Calendar API error: %s", exc)
return f"Calendar API error: {exc}"
logger.warning("Google Calendar list error: %s", exc)
return f"Calendar error listing events for {date}: {exc}"
items = events_result.get("items", [])
if not items:
return f"No events found on {date}."
lines = [f"Calendar events for {date}:\n"]
lines = [f"Calendar events for {date}:"]
for event in items:
start = event["start"].get("dateTime", event["start"].get("date", "Unknown time"))
time_str = _format_event_time(event)
summary = event.get("summary", "Untitled event")
lines.append(f"- {start}: {summary}")
lines.append(f"- {time_str}: {summary}")
return "\n".join(lines)
def _action_check_availability(service: Any, calendar_id: str, date: str) -> str:
"""Return a free/busy summary for the given date."""
time_min, time_max = _time_boundaries(date)
try:
events_result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=time_min,
timeMax=time_max,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
except Exception as exc:
logger.warning("Google Calendar availability check error: %s", exc)
return f"Calendar error checking availability for {date}: {exc}"
items = events_result.get("items", [])
if not items:
return f"No events on {date} — the entire day is free."
lines = [f"Busy slots on {date}:"]
for event in items:
time_str = _format_event_time(event)
summary = event.get("summary", "Untitled event")
lines.append(f"- {time_str}: {summary}")
return "\n".join(lines)
def _action_create(
service: Any,
calendar_id: str,
event_summary: str | None,
event_start: str | None,
event_end: str | None,
) -> str:
"""Create a new calendar event."""
if not event_summary or not event_start or not event_end:
missing = []
if not event_summary:
missing.append("event_summary")
if not event_start:
missing.append("event_start")
if not event_end:
missing.append("event_end")
return f"Cannot create event: missing required fields: {', '.join(missing)}."
event_body = {
"summary": event_summary,
"start": {"dateTime": event_start},
"end": {"dateTime": event_end},
}
try:
created = (
service.events()
.insert(calendarId=calendar_id, body=event_body)
.execute()
)
except Exception as exc:
logger.warning("Google Calendar create error: %s", exc)
return f"Failed to create calendar event: {exc}"
summary = created.get("summary", event_summary)
start = created.get("start", {}).get("dateTime", event_start)
end = created.get("end", {}).get("dateTime", event_end)
return f"Event created: {summary} from {start} to {end}."

View File

@@ -0,0 +1,310 @@
"""
Google Calendar OAuth API endpoints — per-tenant OAuth install + callback.
Endpoints:
GET /api/portal/calendar/install?tenant_id={id}
→ generates HMAC-signed state, returns Google OAuth URL
GET /api/portal/calendar/callback?code={code}&state={state}
→ verifies state, exchanges code for tokens, stores encrypted in channel_connections
GET /api/portal/calendar/{tenant_id}/status
→ returns {"connected": bool}
OAuth state uses the same HMAC-SHA256 signed state pattern as Slack OAuth
(see shared.api.channels.generate_oauth_state / verify_oauth_state).
Token storage:
Token JSON is encrypted with the platform KeyEncryptionService (Fernet) and
stored in channel_connections with channel_type='google_calendar'.
workspace_id is set to str(tenant_id) — Google Calendar is per-tenant,
not per-workspace, so the tenant UUID serves as the workspace identifier.
Token auto-refresh:
The calendar_lookup tool handles refresh via google-auth library.
This module is responsible for initial OAuth install and status checks only.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.channels import generate_oauth_state, verify_oauth_state
from shared.api.rbac import PortalCaller, require_tenant_admin, require_tenant_member
from shared.config import settings
from shared.crypto import KeyEncryptionService
from shared.db import get_session
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
calendar_auth_router = APIRouter(prefix="/api/portal/calendar", tags=["calendar"])
# Google Calendar OAuth scopes — full read+write (locked decision: operators need CRUD)
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
# Google OAuth endpoints
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
# ---------------------------------------------------------------------------
# Helper: build OAuth URL
# ---------------------------------------------------------------------------
def build_calendar_oauth_url(tenant_id: str, secret: str) -> str:
"""
Build a Google OAuth 2.0 authorization URL for Calendar access.
Args:
tenant_id: Tenant UUID as string — embedded in the HMAC-signed state.
secret: HMAC secret for state generation (oauth_state_secret).
Returns:
Full Google OAuth authorization URL ready to redirect the user to.
"""
state = generate_oauth_state(tenant_id=tenant_id, secret=secret)
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={_CALENDAR_SCOPE}"
f"&access_type=offline"
f"&prompt=consent"
f"&state={state}"
)
return f"{_GOOGLE_AUTH_URL}{params}"
def _get_encryption_service() -> KeyEncryptionService:
"""Return the platform-level KeyEncryptionService."""
if not settings.platform_encryption_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PLATFORM_ENCRYPTION_KEY not configured",
)
return KeyEncryptionService(
primary_key=settings.platform_encryption_key,
previous_key=settings.platform_encryption_key_previous,
)
# ---------------------------------------------------------------------------
# Endpoint: GET /install
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/install")
async def calendar_install(
tenant_id: uuid.UUID = Query(...),
caller: PortalCaller = Depends(require_tenant_admin),
) -> dict[str, str]:
"""
Generate the Google Calendar OAuth authorization URL.
Returns a JSON object with a 'url' key. The operator's browser should
be redirected to this URL to begin the Google OAuth consent flow.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
if not settings.google_client_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="GOOGLE_CLIENT_ID not configured",
)
url = build_calendar_oauth_url(
tenant_id=str(tenant_id),
secret=settings.oauth_state_secret,
)
return {"url": url}
# ---------------------------------------------------------------------------
# Callback handler (shared between endpoint and tests)
# ---------------------------------------------------------------------------
async def handle_calendar_callback(
code: str,
state: str,
session: AsyncSession,
) -> str:
"""
Process the Google OAuth callback: verify state, exchange code, store token.
Args:
code: Authorization code from Google.
state: HMAC-signed state parameter.
session: Async DB session for storing the ChannelConnection.
Returns:
Redirect URL string (portal /settings?calendar=connected).
Raises:
HTTPException 400 if state is invalid or token exchange fails.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
# Verify HMAC state to recover tenant_id
try:
tenant_id_str = verify_oauth_state(state=state, secret=settings.oauth_state_secret)
tenant_id = uuid.UUID(tenant_id_str)
except (ValueError, Exception) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid OAuth state: {exc}",
) from exc
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
_GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Google token exchange failed",
)
token_data: dict[str, Any] = response.json()
if "error" in token_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Google OAuth error: {token_data.get('error_description', token_data['error'])}",
)
# Build token JSON for storage (google-auth credentials format)
token_json = json.dumps({
"token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"token_uri": _GOOGLE_TOKEN_URL,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"scopes": [_CALENDAR_SCOPE],
})
# Encrypt before storage
enc_svc = _get_encryption_service()
encrypted_token = enc_svc.encrypt(token_json)
# Upsert ChannelConnection for google_calendar
existing = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = existing.scalar_one_or_none()
if conn is None:
conn = ChannelConnection(
tenant_id=tenant_id,
channel_type=ChannelTypeEnum.GOOGLE_CALENDAR,
workspace_id=str(tenant_id), # tenant UUID as workspace_id
config={"token": encrypted_token},
)
session.add(conn)
else:
conn.config = {"token": encrypted_token}
await session.commit()
return f"{settings.portal_url}/settings?calendar=connected"
# ---------------------------------------------------------------------------
# Endpoint: GET /callback
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/callback")
async def calendar_callback(
code: str = Query(...),
state: str = Query(...),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""
Handle the Google Calendar OAuth callback from Google.
No auth guard — this endpoint receives an external redirect from Google
(no session cookie available during OAuth flow).
Verifies HMAC state, exchanges code for tokens, stores encrypted token,
then redirects to portal /settings?calendar=connected.
"""
redirect_url = await handle_calendar_callback(code=code, state=state, session=session)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
# ---------------------------------------------------------------------------
# Status check helper (for tests)
# ---------------------------------------------------------------------------
async def get_calendar_status(
tenant_id: uuid.UUID,
session: AsyncSession,
) -> dict[str, bool]:
"""
Check if a Google Calendar connection exists for a tenant.
Args:
tenant_id: Tenant UUID to check.
session: Async DB session.
Returns:
{"connected": True} if a ChannelConnection exists, {"connected": False} otherwise.
"""
result = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = result.scalar_one_or_none()
return {"connected": conn is not None}
# ---------------------------------------------------------------------------
# Endpoint: GET /{tenant_id}/status
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/{tenant_id}/status")
async def calendar_status(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> dict[str, bool]:
"""
Check if Google Calendar is connected for a tenant.
Returns {"connected": true} if the tenant has authorized Google Calendar,
{"connected": false} otherwise.
"""
return await get_calendar_status(tenant_id=tenant_id, session=session)

View 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

View 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()