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
|
||||
|
||||
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.
|
||||
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:
|
||||
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."
|
||||
# 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
|
||||
|
||||
time_min = date_obj.strftime("%Y-%m-%dT00:00:00Z")
|
||||
time_max = date_obj.strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
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:
|
||||
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}."
|
||||
|
||||
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