diff --git a/src/gaia/apps/webui/src/components/ConnectorsSection.tsx b/src/gaia/apps/webui/src/components/ConnectorsSection.tsx index a9a3162ab..844b83435 100644 --- a/src/gaia/apps/webui/src/components/ConnectorsSection.tsx +++ b/src/gaia/apps/webui/src/components/ConnectorsSection.tsx @@ -22,6 +22,7 @@ import { // Human-readable labels for well-known OAuth scope URIs. // Unrecognised scopes fall back to the last path segment of the URI. const SCOPE_LABELS: Record = { + // Google / Gmail 'https://www.googleapis.com/auth/gmail.readonly': 'Read emails', 'https://www.googleapis.com/auth/gmail.modify': 'Organize emails (archive, label, trash)', 'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf', @@ -35,6 +36,12 @@ const SCOPE_LABELS: Record = { 'openid': 'Identify you', 'email': 'See your email address', 'profile': 'See your basic profile info', + // Microsoft Graph — email agent scopes (#1770) + 'https://graph.microsoft.com/Mail.ReadWrite': 'Read & organize Outlook mail (archive, label, trash)', + 'https://graph.microsoft.com/Mail.Send': 'Send Outlook mail on your behalf', + 'https://graph.microsoft.com/Calendars.ReadWrite': 'Create & respond to Outlook calendar events', + 'https://graph.microsoft.com/Calendars.Read': 'View Outlook calendar events', + 'https://graph.microsoft.com/User.Read': 'Read your basic Microsoft profile', }; function scopeLabel(scope: string): string { diff --git a/src/gaia/apps/webui/src/components/MessageBubble.tsx b/src/gaia/apps/webui/src/components/MessageBubble.tsx index 30770368c..607edc299 100644 --- a/src/gaia/apps/webui/src/components/MessageBubble.tsx +++ b/src/gaia/apps/webui/src/components/MessageBubble.tsx @@ -509,7 +509,7 @@ export function MessageBubble({ message, isStreaming, showTerminalCursor, agentS {message.role === 'assistant' && !isStreaming && isAuthRequiredMessage(cleanedContent) && ( - + )} {message.role === 'assistant' && !isStreaming && (message.stats || latencyMs != null || message.created_at) && (
diff --git a/src/gaia/apps/webui/src/components/__tests__/EmailConnectCta.test.ts b/src/gaia/apps/webui/src/components/__tests__/EmailConnectCta.test.ts index bd00bb191..9f931b87d 100644 --- a/src/gaia/apps/webui/src/components/__tests__/EmailConnectCta.test.ts +++ b/src/gaia/apps/webui/src/components/__tests__/EmailConnectCta.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; -import { isAuthRequiredMessage } from '../email/EmailConnectCta'; +import { detectProvider, isAuthRequiredMessage } from '../email/EmailConnectCta'; /** * Tests for isAuthRequiredMessage — the CTA detection function that decides @@ -81,4 +81,53 @@ describe('isAuthRequiredMessage', () => { isAuthRequiredMessage('Failed to connect to Lemonade server at port 13305.') ).toBe(false); }); + + // Microsoft-side detection (#1770) + it('returns true for NOT_CONNECTED: microsoft prefix', () => { + expect( + isAuthRequiredMessage('NOT_CONNECTED: microsoft is not currently connected.') + ).toBe(true); + }); + + it('returns true when message mentions connectors → microsoft', () => { + expect( + isAuthRequiredMessage('Please go to Settings → Connectors → Microsoft and reconnect.') + ).toBe(true); + }); + + it('returns true when message mentions connections → microsoft (case-insensitive)', () => { + expect( + isAuthRequiredMessage('Visit Settings → Connections → Microsoft to fix this.') + ).toBe(true); + }); +}); + +describe('detectProvider', () => { + it('returns google when only google is mentioned', () => { + expect(detectProvider('NOT_CONNECTED: google is not currently connected.')).toBe('google'); + }); + + it('returns microsoft when only microsoft is mentioned', () => { + expect(detectProvider('NOT_CONNECTED: microsoft is not currently connected.')).toBe('microsoft'); + }); + + it('returns google when gmail is mentioned', () => { + expect(detectProvider('Please reconnect Gmail to continue.')).toBe('google'); + }); + + it('returns microsoft when outlook is mentioned', () => { + expect(detectProvider('Please reconnect Outlook to continue.')).toBe('microsoft'); + }); + + it('returns both when both providers are mentioned', () => { + expect(detectProvider('Google and Microsoft accounts need reconnecting.')).toBe('both'); + }); + + it('returns both for empty string', () => { + expect(detectProvider('')).toBe('both'); + }); + + it('returns both for an ambiguous message without provider names', () => { + expect(detectProvider('AGENT_NOT_GRANTED: this agent is not granted.')).toBe('both'); + }); }); diff --git a/src/gaia/apps/webui/src/components/email/EmailConnectCta.css b/src/gaia/apps/webui/src/components/email/EmailConnectCta.css index 12c3fdc20..f8120029c 100644 --- a/src/gaia/apps/webui/src/components/email/EmailConnectCta.css +++ b/src/gaia/apps/webui/src/components/email/EmailConnectCta.css @@ -1,13 +1,13 @@ -/* EmailConnectCta — inline Connect Google button rendered next to an +/* EmailConnectCta — inline Connect button(s) rendered next to an * assistant error message when the email agent surfaces a connectors - * auth-required state. Visually distinct from the normal error banner - * so the user sees there's something they can act on, not just a wall - * of text. */ + * auth-required state (Google and/or Microsoft). Visually distinct + * from the normal error banner so the user sees there's something + * they can act on, not just a wall of text. */ .email-connect-cta { display: flex; flex-wrap: wrap; - align-items: center; + align-items: flex-start; gap: 12px; margin-top: 8px; padding: 10px 12px; @@ -26,6 +26,21 @@ min-width: 0; } +/* Container for one or two provider buttons laid out side by side. */ +.email-connect-cta__buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} + +/* Wrapper for a single provider button + its per-provider error. */ +.email-connect-cta__provider-slot { + display: flex; + flex-direction: column; + gap: 4px; +} + .email-connect-cta__icon { color: var(--color-accent, #d96b22); flex-shrink: 0; diff --git a/src/gaia/apps/webui/src/components/email/EmailConnectCta.tsx b/src/gaia/apps/webui/src/components/email/EmailConnectCta.tsx index 3a5e457df..0919a12fb 100644 --- a/src/gaia/apps/webui/src/components/email/EmailConnectCta.tsx +++ b/src/gaia/apps/webui/src/components/email/EmailConnectCta.tsx @@ -4,16 +4,21 @@ /** * EmailConnectCta * - * Inline "Connect Google" button rendered next to an assistant message - * when the email agent surfaces a connectors auth-required error - * (``NOT_CONNECTED:`` or ``AGENT_NOT_GRANTED:`` from - * ``gaia.connectors.formatting.format_connector_error``). The CTA - * triggers the same OAuth flow the user would otherwise reach via - * Settings → Connectors → Google → Connect — without forcing them to - * navigate away from the chat. + * Inline "Connect" button(s) rendered next to an assistant message when the + * email agent surfaces a connectors auth-required error (``NOT_CONNECTED:``, + * ``AGENT_NOT_GRANTED:``, or ``AUTH_REQUIRED:`` from + * ``gaia.connectors.formatting.format_connector_error``). The CTA triggers + * the same OAuth flow the user would otherwise reach via + * Settings → Connectors → (Google|Microsoft) → Connect — without forcing + * them to navigate away from the chat. * * Detection lives in ``isAuthRequiredMessage`` so MessageBubble can * mount this component conditionally on assistant content. + * + * Provider detection: when the error message mentions "microsoft" the CTA + * offers a Microsoft connect button; when it mentions "google" (or the + * ``installed:email`` upgrade message) it offers Google; when the provider + * is ambiguous both buttons are shown so the user can pick. */ import { useCallback, useState } from 'react'; @@ -25,8 +30,7 @@ import './EmailConnectCta.css'; /** Match the canonical prefixes the connectors framework emits. The * prefixes are stable (see ``connectors/formatting.py``); fuzzy - * fallbacks like "Open Settings → Connectors → Google" handle the - * agent-specific override message for ``installed:email``. + * fallbacks handle agent-specific override messages. */ export function isAuthRequiredMessage(content: string): boolean { if (!content) return false; @@ -44,9 +48,39 @@ export function isAuthRequiredMessage(content: string): boolean { ) { return true; } + // Microsoft-side error messages from format_connector_error + if ( + lower.includes('connectors → microsoft') || + lower.includes('connections → microsoft') || + lower.includes('microsoft is not currently connected') + ) { + return true; + } return false; } +/** + * Detect which provider(s) an error message references. + * + * Returns: + * - ``'google'`` — only Google connector mentioned + * - ``'microsoft'`` — only Microsoft connector mentioned + * - ``'both'`` — ambiguous / no specific provider found → show both + */ +export function detectProvider(content: string): 'google' | 'microsoft' | 'both' { + if (!content) return 'both'; + const lower = content.toLowerCase(); + const mentionsGoogle = + lower.includes('google') || + lower.includes('gmail'); + const mentionsMicrosoft = + lower.includes('microsoft') || + lower.includes('outlook'); + if (mentionsGoogle && !mentionsMicrosoft) return 'google'; + if (mentionsMicrosoft && !mentionsGoogle) return 'microsoft'; + return 'both'; +} + // ── OAuth helpers (mirror ConnectorsSection.openAuthUrl) ───────────────────── function openAuthUrl(url: string): void { @@ -60,16 +94,21 @@ function openAuthUrl(url: string): void { } } -// ── Component ──────────────────────────────────────────────────────────────── +// ── Single-provider connect button ──────────────────────────────────────────── -export function EmailConnectCta({ - connectorId = 'google', +function ProviderButton({ + connectorId, + label, + done, + onDone, }: { - connectorId?: string; + connectorId: string; + label: string; + done: boolean; + onDone: () => void; }) { const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); - const [done, setDone] = useState(false); const handleConnect = useCallback(async () => { setBusy(true); @@ -82,35 +121,28 @@ export function EmailConnectCta({ : connector.default_scopes; const r = await api.authorizeConnector(connectorId, scopes); openAuthUrl(r.authorization_url); - setDone(true); + onDone(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } - }, [connectorId]); + }, [connectorId, onDone]); return ( -
-
- - - {done - ? 'A browser tab opened for Google sign-in. Return here when finished.' - : 'Connect your Google account to use Email Triage.'} - -
+
{err && (
@@ -120,3 +152,70 @@ export function EmailConnectCta({
); } + +// ── Component ──────────────────────────────────────────────────────────────── + +export function EmailConnectCta({ + content = '', + connectorId, +}: { + /** The assistant message content — used to detect which provider to surface. */ + content?: string; + /** + * Optional explicit connector override. When provided, only this connector's + * button is shown regardless of content detection. Kept for back-compat with + * callers that hardcode ``connectorId="google"``. + */ + connectorId?: string; +}) { + const [googleDone, setGoogleDone] = useState(false); + const [microsoftDone, setMicrosoftDone] = useState(false); + + // Resolve which provider(s) to surface. Honor an explicit google/microsoft + // override; any other value (or none) falls back to content detection so we + // never render zero connect buttons (no silent dead-end). + const provider = + connectorId === 'google' || connectorId === 'microsoft' + ? connectorId + : detectProvider(content); + + const showGoogle = provider === 'google' || provider === 'both'; + const showMicrosoft = provider === 'microsoft' || provider === 'both'; + + const anyDone = googleDone || microsoftDone; + + return ( +
+
+ + + {anyDone + ? 'A browser tab opened for sign-in. Return here when finished.' + : 'Connect your email account to use Email Triage.'} + +
+
+ {showGoogle && ( + setGoogleDone(true)} + /> + )} + {showMicrosoft && ( + setMicrosoftDone(true)} + /> + )} +
+
+ ); +} diff --git a/tests/test_ui_email_scope_consent.py b/tests/test_ui_email_scope_consent.py new file mode 100644 index 000000000..c996d10d8 --- /dev/null +++ b/tests/test_ui_email_scope_consent.py @@ -0,0 +1,607 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Tests for email connector grant + scope consent in the Agent UI (#1770). + +Covers: +- Scope resolution: installed:email REQUIRED_CONNECTORS returns the full + Google+Microsoft scope set independent of chat-agent activation. +- Negative (a): connector connected but installed:email not granted → + structured AGENT_NOT_GRANTED (not 500). +- Negative (b): granted mail scopes but missing calendar.events → scope-guard + path reports CONNECTION_MISSING_SCOPES; triage/draft/send mail-only still pass. +- Negative (c): no mailbox connected → 503 from get_send_backend + (the fail-loud guard on the #1768-mounted /v1/email surface). +- Legacy migration regression (#1592): builtin:email grant migrates to + installed:email. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("fastapi") +pytest.importorskip("gaia_agent_email") + +from fastapi.testclient import TestClient # noqa: E402 +from gaia_agent_email.agent import EmailTriageAgent # noqa: E402 +from gaia_agent_email.outlook_scopes import ( # noqa: E402 + OUTLOOK_CALENDAR_SCOPES, + OUTLOOK_MAIL_SCOPES, +) +from gaia_agent_email.scopes import ( # noqa: E402 + ALL_SCOPES, + CALENDAR_SCOPES, + GMAIL_SCOPES, +) + +from gaia.agents.registry import AgentRegistration, AgentRegistry # noqa: E402 +from gaia.connectors.context import _agent_context # noqa: E402 +from gaia.connectors.errors import AuthRequiredError # noqa: E402 +from gaia.connectors.grants import ( # noqa: E402 + _LEGACY_KEY_MIGRATIONS, + migrate_legacy_agent_grants, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _email_required_connections() -> list: + """Return the REQUIRED_CONNECTORS for installed:email (from the live class).""" + return list(EmailTriageAgent.REQUIRED_CONNECTORS) + + +# --------------------------------------------------------------------------- +# Scope resolution (positive) — independent of chat-agent activation +# --------------------------------------------------------------------------- + + +class TestScopeResolution: + """installed:email REQUIRED_CONNECTORS returns full scope sets for both + providers, resolved from the registered agent class — NOT from any active + chat-session context.""" + + def test_google_scopes_present(self): + """Resolved Google scopes include all Gmail + Calendar scopes.""" + reqs = _email_required_connections() + google_req = next((r for r in reqs if r.connector_id == "google"), None) + assert ( + google_req is not None + ), "No Google ConnectorRequirement in REQUIRED_CONNECTORS" + + google_scopes = set(google_req.scopes) + for scope in GMAIL_SCOPES: + assert ( + scope in google_scopes + ), f"Missing Gmail scope {scope!r} in REQUIRED_CONNECTORS" + for scope in CALENDAR_SCOPES: + assert ( + scope in google_scopes + ), f"Missing Calendar scope {scope!r} in REQUIRED_CONNECTORS" + + def test_google_scopes_exact(self): + """Google scopes are exactly ALL_SCOPES — no extras, no gaps.""" + reqs = _email_required_connections() + google_req = next(r for r in reqs if r.connector_id == "google") + assert set(google_req.scopes) == set(ALL_SCOPES), ( + f"Google scopes mismatch: got {sorted(google_req.scopes)}, " + f"expected {sorted(ALL_SCOPES)}" + ) + + def test_microsoft_scopes_present(self): + """Resolved Microsoft scopes include mail + calendar scopes.""" + reqs = _email_required_connections() + ms_req = next((r for r in reqs if r.connector_id == "microsoft"), None) + assert ( + ms_req is not None + ), "No Microsoft ConnectorRequirement in REQUIRED_CONNECTORS" + + ms_scopes = set(ms_req.scopes) + for scope in OUTLOOK_MAIL_SCOPES: + assert scope in ms_scopes, f"Missing Outlook mail scope {scope!r}" + for scope in OUTLOOK_CALENDAR_SCOPES: + assert scope in ms_scopes, f"Missing Outlook calendar scope {scope!r}" + + def test_microsoft_scopes_exact(self): + """Microsoft scopes are exactly OUTLOOK_MAIL_SCOPES + OUTLOOK_CALENDAR_SCOPES.""" + reqs = _email_required_connections() + ms_req = next(r for r in reqs if r.connector_id == "microsoft") + expected = set(OUTLOOK_MAIL_SCOPES + OUTLOOK_CALENDAR_SCOPES) + assert set(ms_req.scopes) == expected, ( + f"Microsoft scopes mismatch: got {sorted(ms_req.scopes)}, " + f"expected {sorted(expected)}" + ) + + def test_scope_resolution_independent_of_chat_activation(self): + """Scope requirements come from the agent class, NOT from an active chat session. + + Simulates the common case where a user is on the chat tab (no email agent + active) but opens Settings → Connectors and sees the email agent's + requirements correctly listed. + """ + # Build an isolated registry with only the email agent installed + # (no chat session active, no chat agent selected) + registry = AgentRegistry.__new__(AgentRegistry) + registry._agents = {} + registry._logger = MagicMock() + + # Replicate the installed-agent registration path from + # AgentRegistry._load_entry_point_registration + email_reg = AgentRegistration( + id="email", + name="Email Triage", + description="Email triage agent", + source="installed", + conversation_starters=[], + factory=lambda **kw: None, + agent_dir=None, + models=[], + required_connections=list(EmailTriageAgent.REQUIRED_CONNECTORS), + namespaced_agent_id="installed:email", + ) + registry._agents["email"] = email_reg + + # Verify scope resolution goes through the registry registration — + # independent of any chat session context variable + all_regs = registry.list() + email_entry = next( + r for r in all_regs if r.namespaced_agent_id == "installed:email" + ) + assert len(email_entry.required_connections) == 2 # Google + Microsoft + + google_req = next( + r for r in email_entry.required_connections if r.connector_id == "google" + ) + ms_req = next( + r for r in email_entry.required_connections if r.connector_id == "microsoft" + ) + + assert set(google_req.scopes) == set(ALL_SCOPES) + assert set(ms_req.scopes) == set(OUTLOOK_MAIL_SCOPES + OUTLOOK_CALENDAR_SCOPES) + + def test_required_connectors_has_both_providers(self): + """REQUIRED_CONNECTORS declares exactly two providers: google and microsoft.""" + reqs = _email_required_connections() + connector_ids = {r.connector_id for r in reqs} + assert ( + "google" in connector_ids + ), "Google provider missing from REQUIRED_CONNECTORS" + assert ( + "microsoft" in connector_ids + ), "Microsoft provider missing from REQUIRED_CONNECTORS" + assert len(reqs) == 2, ( + f"Expected exactly 2 REQUIRED_CONNECTORS entries (google + microsoft), " + f"got {len(reqs)}: {[r.connector_id for r in reqs]}" + ) + + +# --------------------------------------------------------------------------- +# Negative (a): connector connected but installed:email not granted → AGENT_NOT_GRANTED +# --------------------------------------------------------------------------- + + +class TestAgentNotGranted: + """connector connected (google/microsoft) but installed:email has no grant + → AuthRequiredError.Reason.AGENT_NOT_GRANTED raised, not 500.""" + + def test_google_connected_no_grant_raises_agent_not_granted(self): + """get_access_token_sync for google raises AGENT_NOT_GRANTED when no grant exists. + + Uses the sync variant to avoid asyncio complications in pytest. + Patches check_agent_grant at its import site in api.py so the eager + grant-check fires before any network call. + """ + from gaia.connectors.api import get_access_token_sync + + # installed:email agent context — as in production tool calls + with _agent_context("installed:email"): + # Patch at the import site in api.py (where it's called) + with patch("gaia.connectors.api.check_agent_grant", return_value=False): + with pytest.raises(AuthRequiredError) as exc_info: + get_access_token_sync( + provider="google", + scopes=list(GMAIL_SCOPES), + ) + err = exc_info.value + assert ( + err.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + ), f"Expected AGENT_NOT_GRANTED, got {err.reason}" + assert err.agent_id == "installed:email" + assert err.provider == "google" + + def test_microsoft_connected_no_grant_raises_agent_not_granted(self): + """get_access_token_sync for microsoft raises AGENT_NOT_GRANTED when no grant exists.""" + from gaia.connectors.api import get_access_token_sync + + with _agent_context("installed:email"): + with patch("gaia.connectors.api.check_agent_grant", return_value=False): + with pytest.raises(AuthRequiredError) as exc_info: + get_access_token_sync( + provider="microsoft", + scopes=list(OUTLOOK_MAIL_SCOPES), + ) + err = exc_info.value + assert err.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + assert err.agent_id == "installed:email" + assert err.provider == "microsoft" + + def test_agent_not_granted_error_is_not_generic_500(self): + """AGENT_NOT_GRANTED maps to 403 (not 500) via _raise_http_for.""" + from gaia.ui.routers.connectors import _raise_http_for + + err = AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider="google", + agent_id="installed:email", + missing_scopes=list(GMAIL_SCOPES), + ) + http_exc = _raise_http_for(err) + assert ( + http_exc.status_code == 403 + ), f"AGENT_NOT_GRANTED must map to 403, got {http_exc.status_code}" + detail = http_exc.detail + assert detail.get("error") == "agent_not_granted" + assert "installed:email" in str(detail.get("agent_id", "")) + assert "google" in str(detail.get("connector_id", "")) + + def test_agent_not_granted_without_context_bypasses_grant_check(self): + """When no agent context is set, the grant check is bypassed (None agent_id). + + This documents the escape hatch: CLI/debug callers without a context + are not subject to the grant gate. + """ + from gaia.connectors.api import get_access_token_sync + from gaia.connectors.context import current_agent_id + + # No _agent_context active + assert current_agent_id() is None + + # Without a context, check_agent_grant is not called even if no grant exists. + # The call will fail at load_connection (no stored connection) — but NOT + # at the grant check stage. + with patch( + "gaia.connectors.api.check_agent_grant", return_value=False + ) as mock_grant: + with patch("gaia.connectors.api.get_provider"): + with patch("gaia.connectors.api.load_connection", return_value=None): + with pytest.raises(AuthRequiredError) as exc_info: + get_access_token_sync( + provider="google", scopes=list(GMAIL_SCOPES) + ) + + # The error should be NOT_CONNECTED (from load_connection → None), + # not AGENT_NOT_GRANTED. + assert exc_info.value.reason is AuthRequiredError.Reason.NOT_CONNECTED + # check_agent_grant was NOT called (no agent context) + mock_grant.assert_not_called() + + +# --------------------------------------------------------------------------- +# Negative (b): granted mail scopes but missing calendar.events +# --------------------------------------------------------------------------- + + +class TestMissingCalendarScope: + """Granted gmail.modify + gmail.send but NOT calendar.events. + + The existing scope-guard plumbing reports CONNECTION_MISSING_SCOPES for + calendar tool calls; triage/draft/send (which only need gmail.modify and + gmail.send) still work. + + Calendar tools are agent-loop-only, NOT on the REST API — this is a + REGRESSION test on the existing fail-loud plumbing. No new agent logic. + """ + + MAIL_ONLY_SCOPES = list(GMAIL_SCOPES) # gmail.modify + gmail.send + CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events" + + def test_missing_calendar_scope_raises_connection_missing_scopes(self): + """get_access_token_sync for calendar.events with mail-only OAuth scopes + raises CONNECTION_MISSING_SCOPES (not a silent empty return or 500). + + Simulates: installed:email is granted in the ledger (check_agent_grant + passes), but the stored OAuth token only has gmail.modify + gmail.send — + no calendar.events — so the OAuth scope coverage check fires. + """ + from gaia.connectors.api import get_access_token_sync + + calendar_scope = self.CALENDAR_SCOPE + + # installed:email has a grant for the calendar scope in the grant ledger, + # but the stored OAuth connection lacks the calendar.events scope. + with _agent_context("installed:email"): + # Patch at the import site in api.py — check_agent_grant returns True + # (agent is granted), so we proceed to the OAuth scope coverage check. + with patch("gaia.connectors.api.check_agent_grant", return_value=True): + with patch("gaia.connectors.api.get_provider"): + with patch( + "gaia.connectors.api.load_connection", + return_value={ + "scopes": self.MAIL_ONLY_SCOPES, + "account_email": "user@example.com", + }, + ): + with pytest.raises(AuthRequiredError) as exc_info: + get_access_token_sync( + provider="google", + scopes=[calendar_scope], + ) + err = exc_info.value + assert ( + err.reason is AuthRequiredError.Reason.CONNECTION_MISSING_SCOPES + ), f"Expected CONNECTION_MISSING_SCOPES, got {err.reason}" + assert calendar_scope in err.missing_scopes + + def test_missing_calendar_scope_maps_to_403_with_missing_scopes(self): + """CONNECTION_MISSING_SCOPES maps to 403 + missing_scopes payload via router.""" + from gaia.ui.routers.connectors import _raise_http_for + + calendar_scope = self.CALENDAR_SCOPE + err = AuthRequiredError( + AuthRequiredError.Reason.CONNECTION_MISSING_SCOPES, + provider="google", + agent_id="installed:email", + missing_scopes=[calendar_scope], + ) + http_exc = _raise_http_for(err) + # CONNECTION_MISSING_SCOPES is not NOT_CONNECTED/REAUTH_REQUIRED, so + # it falls through to the 403 branch in _raise_http_for + assert http_exc.status_code == 403 + detail = http_exc.detail + assert calendar_scope in detail.get("missing_scopes", []) + + def test_mail_scopes_sufficient_for_grant_check(self): + """Triage/draft/send (gmail.modify + gmail.send) pass grant check without calendar. + + check_agent_grant passes when the required scopes are a subset of the + granted mail scopes. + """ + from gaia.connectors.grants import check_agent_grant + + # Simulate ledger: installed:email has gmail.modify + gmail.send + with patch( + "gaia.connectors.grants.list_agent_grants", + return_value={"installed:email": self.MAIL_ONLY_SCOPES}, + ): + # gmail.modify alone — passes (triage read/organize) + assert check_agent_grant( + "google", + "installed:email", + ["https://www.googleapis.com/auth/gmail.modify"], + ) + # gmail.send alone — passes (send/reply) + assert check_agent_grant( + "google", + "installed:email", + ["https://www.googleapis.com/auth/gmail.send"], + ) + # calendar.events alone — fails (calendar tool guard fires) + assert not check_agent_grant( + "google", + "installed:email", + [self.CALENDAR_SCOPE], + ) + + def test_calendar_scope_required_for_full_email_required_connectors(self): + """calendar.events is present in installed:email's REQUIRED_CONNECTORS Google entry. + + This ensures the Connectors panel consent dialog asks for the full + scope set (including calendar) upfront on first connect. + """ + reqs = _email_required_connections() + google_req = next(r for r in reqs if r.connector_id == "google") + assert self.CALENDAR_SCOPE in google_req.scopes, ( + f"calendar.events must be in REQUIRED_CONNECTORS Google scopes " + f"so the consent dialog asks for it. Got: {google_req.scopes}" + ) + + +# --------------------------------------------------------------------------- +# Negative (c): no mailbox connected → 503 from get_send_backend +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def ui_client(): + """TestClient for the UI backend without lifespan startup (#1297 hang guard).""" + from gaia.ui.server import create_app + + app = create_app(db_path=":memory:") + # Skip lifespan (connectors sync / MCP reload) — it hangs in bare test env (#1297) + yield TestClient(app, raise_server_exceptions=True) + + +class TestNoMailboxConnected: + """Absence of any connected mailbox → 503 (fail-loud, not 500/200/empty). + + Tests the get_send_backend guard in the email REST surface mounted at + /v1/email by the #1768 router. + """ + + def test_get_send_backend_raises_http_503_no_mailbox(self): + """get_send_backend raises HTTPException(503) when no mailbox connected.""" + from fastapi import HTTPException + from gaia_agent_email.api_routes import get_send_backend + + with patch( + "gaia_agent_email.api_routes.connected_mailbox_providers", + return_value=[], + ): + with pytest.raises(HTTPException) as exc_info: + get_send_backend() + + exc = exc_info.value + assert exc.status_code == 503 + detail = exc.detail or "" + assert ( + "mailbox" in detail.lower() or "connect" in detail.lower() + ), f"503 detail should be actionable, got: {detail!r}" + + def test_email_health_always_200_via_ui_backend(self, ui_client): + """GET /v1/email/health is always 200 — not gated by mailbox connection.""" + resp = ui_client.get("/v1/email/health") + assert resp.status_code == 200 + assert resp.json().get("status") == "ok" + + def test_triage_does_not_require_mailbox(self, ui_client): + """POST /v1/email/triage works without a connected mailbox (analyzes payload only). + + Triage is pass-by-value — it receives the email in the request body + and never reads from a live mailbox. A 503 here would be a regression. + """ + from unittest.mock import patch + + from gaia_agent_email.contract import ( + EmailCategory, + EmailTriageResponse, + EmailTriageResult, + ) + + stub_resp = EmailTriageResponse( + request_kind="single", + result=EmailTriageResult( + category=EmailCategory.FYI, + summary="Test summary.", + action_items=[], + ), + ) + + with patch( + "gaia_agent_email.api_routes.EmailTriageService.triage_request", + return_value=stub_resp, + ): + payload = { + "schema_version": "2.0", + "payload": { + "kind": "single", + "principal": {"name": "User", "email": "user@example.com"}, + "message": { + "message_id": "msg-001", + "from": {"name": "Sender", "email": "sender@example.com"}, + "subject": "Hello", + "body": "Just a quick note.", + }, + }, + } + resp = ui_client.post("/v1/email/triage", json=payload) + + assert ( + resp.status_code == 200 + ), f"Triage should succeed without a mailbox connection, got {resp.status_code}: {resp.text}" + + def test_email_routes_mounted_at_ui_backend(self, ui_client): + """The #1768 email router is mounted at /v1/email on the UI backend.""" + resp = ui_client.get("/v1/email/version") + assert resp.status_code == 200 + assert "apiVersion" in resp.json() + + +# --------------------------------------------------------------------------- +# Legacy migration regression (#1592): builtin:email → installed:email +# --------------------------------------------------------------------------- + + +class TestLegacyGrantMigration: + """builtin:email grant entries migrate to installed:email at startup.""" + + def test_legacy_key_in_migration_table(self): + """'builtin:email' is in the _LEGACY_KEY_MIGRATIONS table.""" + assert "builtin:email" in _LEGACY_KEY_MIGRATIONS + assert _LEGACY_KEY_MIGRATIONS["builtin:email"] == "installed:email" + + def test_legacy_grant_migrated_to_installed_key(self): + """migrate_legacy_agent_grants copies builtin:email → installed:email. + + Uses pyfakefs to provide an isolated filesystem for the grants ledger + so the test never touches the real ~/.gaia/grants.json. + """ + initial_data = { + "google": { + "builtin:email": ["https://www.googleapis.com/auth/gmail.modify"], + } + } + + captured_write: dict = {} + + with ( + patch( + "gaia.connectors.grants.load_grants", + return_value=initial_data, + ), + patch( + "gaia.connectors.grants._save_grants_locked", + side_effect=lambda d: captured_write.update(d), + ), + ): + migrate_legacy_agent_grants() + + # After migration, installed:email should have the scopes; + # builtin:email should be gone. + google_grants = captured_write.get("google", {}) + assert ( + "installed:email" in google_grants + ), "installed:email not present after migration" + assert ( + "builtin:email" not in google_grants + ), "builtin:email still present after migration — should be removed" + assert google_grants["installed:email"] == [ + "https://www.googleapis.com/auth/gmail.modify" + ] + + def test_migration_idempotent_when_both_keys_present(self): + """If installed:email already has a grant, builtin:email is just removed.""" + initial_data = { + "google": { + "builtin:email": ["https://www.googleapis.com/auth/gmail.modify"], + "installed:email": [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send", + ], + } + } + + captured_write: dict = {} + + with ( + patch("gaia.connectors.grants.load_grants", return_value=initial_data), + patch( + "gaia.connectors.grants._save_grants_locked", + side_effect=lambda d: captured_write.update(d), + ), + ): + migrate_legacy_agent_grants() + + google_grants = captured_write.get("google", {}) + assert "builtin:email" not in google_grants + # installed:email keeps its richer scope set (not overwritten by old entry) + assert set(google_grants["installed:email"]) == { + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send", + } + + def test_migration_noop_when_no_legacy_keys(self): + """Migration is a no-op (no write) when the ledger has no legacy keys.""" + initial_data = { + "google": { + "installed:email": ["https://www.googleapis.com/auth/gmail.modify"], + } + } + + save_called: list = [] + + with ( + patch("gaia.connectors.grants.load_grants", return_value=initial_data), + patch( + "gaia.connectors.grants._save_grants_locked", + side_effect=lambda d: save_called.append(d), + ), + ): + migrate_legacy_agent_grants() + + # No save when nothing changed + assert ( + len(save_called) == 0 + ), "_save_grants_locked called unexpectedly when no migration needed"