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:
52
migrations/versions/013_google_calendar_channel.py
Normal file
52
migrations/versions/013_google_calendar_channel.py
Normal 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)})"
|
||||||
|
)
|
||||||
@@ -1,108 +1,302 @@
|
|||||||
"""
|
"""
|
||||||
Built-in tool: calendar_lookup
|
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):
|
Authentication:
|
||||||
1. GOOGLE_SERVICE_ACCOUNT_KEY env var — JSON key for service account impersonation
|
Tokens are stored per-tenant in channel_connections (channel_type='google_calendar').
|
||||||
2. Per-tenant OAuth (future: Phase 3 portal) — not yet implemented
|
The tenant admin must complete the OAuth flow via /api/portal/calendar/install first.
|
||||||
3. Graceful degradation: returns informative message if not configured
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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__)
|
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(
|
async def calendar_lookup(
|
||||||
date: str,
|
date: str,
|
||||||
|
action: str = "list",
|
||||||
|
event_summary: str | None = None,
|
||||||
|
event_start: str | None = None,
|
||||||
|
event_end: str | None = None,
|
||||||
calendar_id: str = "primary",
|
calendar_id: str = "primary",
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
_session: Any = None, # Injected in tests; production uses DB session from task context
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Look up calendar events for a specific date.
|
Look up, check availability, or create Google Calendar events for a specific date.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
date: Date in YYYY-MM-DD format.
|
date: Date in YYYY-MM-DD format (required).
|
||||||
calendar_id: Google Calendar ID. Defaults to 'primary'.
|
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:
|
Returns:
|
||||||
Formatted string listing events for the given date,
|
Natural language string describing the result.
|
||||||
or an informative message if Google Calendar is not configured.
|
|
||||||
"""
|
"""
|
||||||
service_account_key_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY", "")
|
# Guard: tenant_id is required to look up per-tenant OAuth token
|
||||||
if not service_account_key_json:
|
if not tenant_id:
|
||||||
return (
|
return "Calendar not available: missing tenant context."
|
||||||
"Calendar lookup is not configured. "
|
|
||||||
"Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to enable calendar access."
|
# 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:
|
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(
|
# Load per-tenant OAuth token from channel_connections
|
||||||
None,
|
try:
|
||||||
_fetch_calendar_events_sync,
|
from sqlalchemy import select
|
||||||
service_account_key_json,
|
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
|
||||||
calendar_id,
|
|
||||||
date,
|
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:
|
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."
|
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(
|
return result_str
|
||||||
service_account_key_json: str,
|
|
||||||
calendar_id: str,
|
|
||||||
|
def _execute_calendar_action(
|
||||||
|
creds: Any,
|
||||||
|
action: str,
|
||||||
date: str,
|
date: str,
|
||||||
|
calendar_id: str,
|
||||||
|
event_summary: str | None,
|
||||||
|
event_start: str | None,
|
||||||
|
event_end: str | None,
|
||||||
) -> str:
|
) -> 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:
|
if build is None:
|
||||||
from google.oauth2 import service_account
|
|
||||||
from googleapiclient.discovery import build
|
|
||||||
except ImportError:
|
|
||||||
return (
|
return (
|
||||||
"Google Calendar library not installed. "
|
"Google Calendar library not installed. "
|
||||||
"Run: uv add google-api-python-client google-auth"
|
"Run: uv add google-api-python-client google-auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key_data = json.loads(service_account_key_json)
|
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
|
||||||
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"],
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
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:
|
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 = (
|
events_result = (
|
||||||
service.events()
|
service.events()
|
||||||
.list(
|
.list(
|
||||||
@@ -115,17 +309,89 @@ def _fetch_calendar_events_sync(
|
|||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Google Calendar API error: %s", exc)
|
logger.warning("Google Calendar list error: %s", exc)
|
||||||
return f"Calendar API error: {exc}"
|
return f"Calendar error listing events for {date}: {exc}"
|
||||||
|
|
||||||
items = events_result.get("items", [])
|
items = events_result.get("items", [])
|
||||||
if not items:
|
if not items:
|
||||||
return f"No events found on {date}."
|
return f"No events found on {date}."
|
||||||
|
|
||||||
lines = [f"Calendar events for {date}:\n"]
|
lines = [f"Calendar events for {date}:"]
|
||||||
for event in items:
|
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")
|
summary = event.get("summary", "Untitled event")
|
||||||
lines.append(f"- {start}: {summary}")
|
lines.append(f"- {time_str}: {summary}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
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}."
|
||||||
|
|||||||
310
packages/shared/shared/api/calendar_auth.py
Normal file
310
packages/shared/shared/api/calendar_auth.py
Normal 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)
|
||||||
205
tests/unit/test_calendar_auth.py
Normal file
205
tests/unit/test_calendar_auth.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Google Calendar OAuth endpoints.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- /install endpoint returns OAuth URL with HMAC state
|
||||||
|
- /callback verifies state, stores encrypted token in DB
|
||||||
|
- /status returns connected=True when token exists, False otherwise
|
||||||
|
- HMAC state generation and verification work correctly
|
||||||
|
- Missing credentials configuration handled gracefully
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET = "test-oauth-state-secret"
|
||||||
|
_TENANT_ID = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HMAC state helper tests (reuse from channels.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_install_builds_oauth_url():
|
||||||
|
"""
|
||||||
|
calendar_install endpoint returns a dict with a 'url' key pointing at
|
||||||
|
accounts.google.com/o/oauth2/v2/auth.
|
||||||
|
"""
|
||||||
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
||||||
|
|
||||||
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
||||||
|
assert "accounts.google.com/o/oauth2/v2/auth" in url
|
||||||
|
assert "client_id=" in url
|
||||||
|
assert "scope=" in url
|
||||||
|
assert "state=" in url
|
||||||
|
assert "access_type=offline" in url
|
||||||
|
assert "prompt=consent" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_oauth_url_contains_signed_state():
|
||||||
|
"""State parameter in the OAuth URL encodes the tenant_id."""
|
||||||
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
||||||
|
from shared.api.channels import verify_oauth_state
|
||||||
|
|
||||||
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
||||||
|
|
||||||
|
# Extract state from URL
|
||||||
|
import urllib.parse
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
state = params["state"][0]
|
||||||
|
|
||||||
|
# Verify the state recovers the tenant_id
|
||||||
|
recovered = verify_oauth_state(state=state, secret=_SECRET)
|
||||||
|
assert recovered == _TENANT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_oauth_url_uses_calendar_scope():
|
||||||
|
"""OAuth URL requests full Google Calendar scope."""
|
||||||
|
from shared.api.calendar_auth import build_calendar_oauth_url
|
||||||
|
|
||||||
|
url = build_calendar_oauth_url(tenant_id=_TENANT_ID, secret=_SECRET)
|
||||||
|
assert "googleapis.com/auth/calendar" in url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Callback token exchange and storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_callback_stores_encrypted_token():
|
||||||
|
"""
|
||||||
|
handle_calendar_callback() exchanges code for tokens, encrypts them,
|
||||||
|
and upserts a ChannelConnection with channel_type='google_calendar'.
|
||||||
|
"""
|
||||||
|
from shared.api.calendar_auth import handle_calendar_callback
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.execute = AsyncMock()
|
||||||
|
mock_session.add = MagicMock()
|
||||||
|
mock_session.commit = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate no existing connection (first install)
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
token_response = {
|
||||||
|
"access_token": "ya29.test_access_token",
|
||||||
|
"refresh_token": "1//test_refresh_token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("shared.api.calendar_auth.httpx.AsyncClient") as mock_client_cls,
|
||||||
|
patch("shared.api.calendar_auth.KeyEncryptionService") as mock_enc_cls,
|
||||||
|
patch("shared.api.calendar_auth.settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_settings.oauth_state_secret = _SECRET
|
||||||
|
mock_settings.google_client_id = "test-client-id"
|
||||||
|
mock_settings.google_client_secret = "test-client-secret"
|
||||||
|
mock_settings.portal_url = "http://localhost:3000"
|
||||||
|
mock_settings.platform_encryption_key = "test-key"
|
||||||
|
mock_settings.platform_encryption_key_previous = ""
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = token_response
|
||||||
|
|
||||||
|
mock_http = AsyncMock()
|
||||||
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
||||||
|
mock_http.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_http.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_cls.return_value = mock_http
|
||||||
|
|
||||||
|
mock_enc = MagicMock()
|
||||||
|
mock_enc.encrypt.return_value = "encrypted_token_data"
|
||||||
|
mock_enc_cls.return_value = mock_enc
|
||||||
|
|
||||||
|
# Generate a valid state
|
||||||
|
from shared.api.channels import generate_oauth_state
|
||||||
|
state = generate_oauth_state(tenant_id=_TENANT_ID, secret=_SECRET)
|
||||||
|
|
||||||
|
redirect_url = await handle_calendar_callback(
|
||||||
|
code="test_auth_code",
|
||||||
|
state=state,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should redirect to portal settings
|
||||||
|
assert "settings" in redirect_url or "calendar" in redirect_url
|
||||||
|
# Session.add should have been called (new ChannelConnection)
|
||||||
|
mock_session.add.assert_called_once()
|
||||||
|
# Encryption was called
|
||||||
|
mock_enc.encrypt.assert_called_once()
|
||||||
|
# The ChannelConnection passed to add should have google_calendar type
|
||||||
|
conn = mock_session.add.call_args[0][0]
|
||||||
|
assert "google_calendar" in str(conn.channel_type).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_callback_invalid_state_raises():
|
||||||
|
"""handle_calendar_callback raises HTTPException for tampered state."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from shared.api.calendar_auth import handle_calendar_callback
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
|
||||||
|
with patch("shared.api.calendar_auth.settings") as mock_settings:
|
||||||
|
mock_settings.oauth_state_secret = _SECRET
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await handle_calendar_callback(
|
||||||
|
code="some_code",
|
||||||
|
state="TAMPERED.INVALID",
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_status_connected():
|
||||||
|
"""get_calendar_status returns connected=True when ChannelConnection exists."""
|
||||||
|
from shared.api.calendar_auth import get_calendar_status
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
# Simulate existing connection
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_conn
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
status = await get_calendar_status(tenant_id=tenant_id, session=mock_session)
|
||||||
|
assert status["connected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_status_not_connected():
|
||||||
|
"""get_calendar_status returns connected=False when no ChannelConnection exists."""
|
||||||
|
from shared.api.calendar_auth import get_calendar_status
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
tenant_id = uuid.uuid4()
|
||||||
|
status = await get_calendar_status(tenant_id=tenant_id, session=mock_session)
|
||||||
|
assert status["connected"] is False
|
||||||
423
tests/unit/test_calendar_lookup.py
Normal file
423
tests/unit/test_calendar_lookup.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the per-tenant OAuth calendar_lookup tool.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Returns "not configured" message when no tenant_id provided
|
||||||
|
- Returns "not connected" message when no ChannelConnection exists for tenant
|
||||||
|
- action="list" calls Google Calendar API and returns formatted event list
|
||||||
|
- action="check_availability" returns free/busy summary
|
||||||
|
- action="create" creates an event and returns confirmation
|
||||||
|
- Token refresh write-back: updated credentials written to DB
|
||||||
|
- All responses are natural language strings (no raw JSON)
|
||||||
|
- API errors return human-readable messages
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TENANT_ID = str(uuid.uuid4())
|
||||||
|
_DATE = "2026-03-26"
|
||||||
|
|
||||||
|
# Fake encrypted token JSON stored in channel_connections.config
|
||||||
|
_FAKE_ENCRYPTED_TOKEN = "gAAAAAB..."
|
||||||
|
|
||||||
|
# Decrypted token dict (as would come from Google OAuth)
|
||||||
|
_FAKE_TOKEN_DICT = {
|
||||||
|
"token": "ya29.test_access_token",
|
||||||
|
"refresh_token": "1//test_refresh_token",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"client_id": "test-client-id",
|
||||||
|
"client_secret": "test-client-secret",
|
||||||
|
"scopes": ["https://www.googleapis.com/auth/calendar"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sample Google Calendar events response
|
||||||
|
_FAKE_EVENTS = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"summary": "Team Standup",
|
||||||
|
"start": {"dateTime": "2026-03-26T09:00:00+00:00"},
|
||||||
|
"end": {"dateTime": "2026-03-26T09:30:00+00:00"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Sprint Planning",
|
||||||
|
"start": {"dateTime": "2026-03-26T14:00:00+00:00"},
|
||||||
|
"end": {"dateTime": "2026-03-26T15:00:00+00:00"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Common patch targets
|
||||||
|
_PATCH_ENC = "orchestrator.tools.builtins.calendar_lookup.KeyEncryptionService"
|
||||||
|
_PATCH_CREDS = "orchestrator.tools.builtins.calendar_lookup.google_credentials_from_token"
|
||||||
|
_PATCH_BUILD = "orchestrator.tools.builtins.calendar_lookup.build"
|
||||||
|
_PATCH_SETTINGS = "orchestrator.tools.builtins.calendar_lookup.settings"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_session(conn_config: dict | None = None):
|
||||||
|
"""Build a mock AsyncSession that returns a ChannelConnection or None."""
|
||||||
|
session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
if conn_config is not None:
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.id = uuid.uuid4()
|
||||||
|
mock_conn.config = conn_config
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_conn
|
||||||
|
else:
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
session.execute.return_value = mock_result
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _make_enc_mock():
|
||||||
|
"""Create a mock KeyEncryptionService with decrypt returning the fake token JSON."""
|
||||||
|
import json
|
||||||
|
mock_enc = MagicMock()
|
||||||
|
mock_enc.decrypt.return_value = json.dumps(_FAKE_TOKEN_DICT)
|
||||||
|
mock_enc.encrypt.return_value = "new_encrypted_token"
|
||||||
|
return mock_enc
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_settings():
|
||||||
|
"""Create mock settings with encryption key configured."""
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.platform_encryption_key = "test-key"
|
||||||
|
mock_settings.platform_encryption_key_previous = ""
|
||||||
|
return mock_settings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# No tenant_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_no_tenant_id_returns_message():
|
||||||
|
"""calendar_lookup without tenant_id returns a helpful error message."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
result = await calendar_lookup(date=_DATE)
|
||||||
|
assert "tenant" in result.lower() or "not available" in result.lower()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Not connected
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_not_connected_returns_message():
|
||||||
|
"""calendar_lookup with no ChannelConnection returns 'not connected' message."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config=None)
|
||||||
|
|
||||||
|
# Pass _session directly to bypass DB session creation
|
||||||
|
result = await calendar_lookup(date=_DATE, tenant_id=_TENANT_ID, _session=mock_session)
|
||||||
|
|
||||||
|
assert "not connected" in result.lower() or "connect" in result.lower()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# action="list"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_list_returns_formatted_events():
|
||||||
|
"""
|
||||||
|
action="list" returns a natural-language event list.
|
||||||
|
No raw JSON — results are human-readable strings.
|
||||||
|
"""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.token = "ya29.test_access_token"
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_events_list = MagicMock()
|
||||||
|
mock_events_list.execute.return_value = _FAKE_EVENTS
|
||||||
|
mock_service.events.return_value.list.return_value = mock_events_list
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="list",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "Team Standup" in result
|
||||||
|
assert "Sprint Planning" in result
|
||||||
|
# No raw JSON
|
||||||
|
assert "{" not in result or "items" not in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_list_no_events():
|
||||||
|
"""action="list" with no events returns a 'no events' message."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="list",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "no event" in result.lower() or "free" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# action="check_availability"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_check_availability_with_events():
|
||||||
|
"""action="check_availability" returns busy slot summary when events exist."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.events.return_value.list.return_value.execute.return_value = _FAKE_EVENTS
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="check_availability",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "busy" in result.lower() or "slot" in result.lower() or "standup" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_check_availability_free_day():
|
||||||
|
"""action="check_availability" with no events returns 'entire day is free'."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="check_availability",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "free" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# action="create"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_create_event():
|
||||||
|
"""action="create" inserts an event and returns confirmation."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
created_event = {
|
||||||
|
"id": "abc123",
|
||||||
|
"summary": "Product Demo",
|
||||||
|
"start": {"dateTime": "2026-03-26T10:00:00+00:00"},
|
||||||
|
"end": {"dateTime": "2026-03-26T11:00:00+00:00"},
|
||||||
|
}
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.events.return_value.insert.return_value.execute.return_value = created_event
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="create",
|
||||||
|
event_summary="Product Demo",
|
||||||
|
event_start="2026-03-26T10:00:00+00:00",
|
||||||
|
event_end="2026-03-26T11:00:00+00:00",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "created" in result.lower() or "product demo" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_create_missing_fields():
|
||||||
|
"""action="create" without event_summary returns an error message."""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = _make_enc_mock()
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
result = await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="create",
|
||||||
|
# No event_summary, event_start, event_end
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "error" in result.lower() or "required" in result.lower() or "missing" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token refresh write-back
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_lookup_token_refresh_writeback():
|
||||||
|
"""
|
||||||
|
When credentials.token changes after an API call (refresh occurred),
|
||||||
|
the updated token should be encrypted and written back to channel_connections.
|
||||||
|
"""
|
||||||
|
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
|
||||||
|
|
||||||
|
conn_id = uuid.uuid4()
|
||||||
|
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
|
||||||
|
# Get the mock connection to track updates
|
||||||
|
mock_conn = mock_session.execute.return_value.scalar_one_or_none.return_value
|
||||||
|
mock_conn.id = conn_id
|
||||||
|
mock_conn.config = {"token": _FAKE_ENCRYPTED_TOKEN}
|
||||||
|
|
||||||
|
# Credentials that change token after API call (simulating refresh)
|
||||||
|
original_token = "ya29.original_token"
|
||||||
|
refreshed_token = "ya29.refreshed_token"
|
||||||
|
|
||||||
|
mock_creds = MagicMock()
|
||||||
|
mock_creds.token = original_token
|
||||||
|
mock_creds.refresh_token = "1//refresh_token"
|
||||||
|
mock_creds.expired = False
|
||||||
|
mock_creds.valid = True
|
||||||
|
|
||||||
|
def simulate_api_call_that_refreshes():
|
||||||
|
"""Simulate the side effect of token refresh during API call."""
|
||||||
|
mock_creds.token = refreshed_token
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.events.return_value.list.return_value.execute.side_effect = simulate_api_call_that_refreshes
|
||||||
|
|
||||||
|
mock_enc = _make_enc_mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(_PATCH_ENC) as mock_enc_cls,
|
||||||
|
patch(_PATCH_CREDS) as mock_creds_fn,
|
||||||
|
patch(_PATCH_BUILD) as mock_build,
|
||||||
|
patch(_PATCH_SETTINGS, _make_mock_settings()),
|
||||||
|
):
|
||||||
|
mock_enc_cls.return_value = mock_enc
|
||||||
|
mock_creds_fn.return_value = mock_creds
|
||||||
|
mock_build.return_value = mock_service
|
||||||
|
|
||||||
|
await calendar_lookup(
|
||||||
|
date=_DATE,
|
||||||
|
action="list",
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
_session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# encrypt should have been called for write-back
|
||||||
|
mock_enc.encrypt.assert_called()
|
||||||
|
# session.commit should have been called to persist the updated token
|
||||||
|
mock_session.commit.assert_called()
|
||||||
Reference in New Issue
Block a user