diff --git a/migrations/versions/013_google_calendar_channel.py b/migrations/versions/013_google_calendar_channel.py new file mode 100644 index 0000000..ca3abc0 --- /dev/null +++ b/migrations/versions/013_google_calendar_channel.py @@ -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)})" + ) diff --git a/packages/orchestrator/orchestrator/tools/builtins/calendar_lookup.py b/packages/orchestrator/orchestrator/tools/builtins/calendar_lookup.py index 0067a70..ee4f4ed 100644 --- a/packages/orchestrator/orchestrator/tools/builtins/calendar_lookup.py +++ b/packages/orchestrator/orchestrator/tools/builtins/calendar_lookup.py @@ -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}." diff --git a/packages/shared/shared/api/calendar_auth.py b/packages/shared/shared/api/calendar_auth.py new file mode 100644 index 0000000..4583b94 --- /dev/null +++ b/packages/shared/shared/api/calendar_auth.py @@ -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) diff --git a/tests/unit/test_calendar_auth.py b/tests/unit/test_calendar_auth.py new file mode 100644 index 0000000..fdda7bb --- /dev/null +++ b/tests/unit/test_calendar_auth.py @@ -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 diff --git a/tests/unit/test_calendar_lookup.py b/tests/unit/test_calendar_lookup.py new file mode 100644 index 0000000..efc8022 --- /dev/null +++ b/tests/unit/test_calendar_lookup.py @@ -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()