Refactor _pending_scout_oauth_states from a tuple to a dict carrying mode (reconnect|create), draft fields, and a transient encrypted token. Add authorize-draft, session-labels, and cloud/finalize endpoints so the scout row is created only when the flow completes — abandoned flows leave no orphan rows. Zero-trust: the encrypted token lives only in the in-memory session (<=15 min) until finalize persists it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.9 KiB
Python
230 lines
8.9 KiB
Python
"""Tests for cloud scout CRUD routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from app.db import get_session
|
|
from app.integrations import encrypt_token
|
|
from app.main import app
|
|
from app.models import CloudScoutConfig
|
|
from tests.conftest import _TestSessionLocal, make_jwt, TEST_USER_IDS
|
|
|
|
|
|
def _auth_headers(tier: str = "power") -> dict:
|
|
return {"Authorization": f"Bearer {make_jwt(tier)}"}
|
|
|
|
|
|
async def _test_get_session():
|
|
async with _TestSessionLocal() as session:
|
|
yield session
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _override_session():
|
|
# FastAPI resolves Depends() by the original function object, so patching the
|
|
# module-level name does not take effect — use dependency_overrides instead.
|
|
app.dependency_overrides[get_session] = _test_get_session
|
|
yield
|
|
app.dependency_overrides.pop(get_session, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_cloud_scout_defaults_schedule():
|
|
payload = {
|
|
"name": "Inbox",
|
|
"provider": "gmail",
|
|
"data_types": [],
|
|
"prompt_template": "client requests",
|
|
"auto_trash_spam": True,
|
|
# schedule_cron omitted → server default
|
|
}
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers())
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert body["name"] == "Inbox"
|
|
assert body["provider"] == "gmail"
|
|
assert body["auto_trash_spam"] is True
|
|
assert body["prompt_template"] == "client requests"
|
|
assert body["schedule_cron"] # non-empty default applied
|
|
assert body["oauth_connected"] is False
|
|
assert body["gmail_address"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_cloud_scouts_returns_only_own():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
await client.post(
|
|
"/api/v1/scouts/cloud",
|
|
json={"name": "A", "provider": "gmail"},
|
|
headers=_auth_headers(),
|
|
)
|
|
resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers())
|
|
assert resp.status_code == 200
|
|
rows = resp.json()
|
|
assert all(r["provider"] == "gmail" for r in rows)
|
|
assert any(r["name"] == "A" for r in rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_cloud_scout_applies_filter_and_autotrash():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
created = (await client.post(
|
|
"/api/v1/scouts/cloud",
|
|
json={"name": "B", "provider": "gmail"},
|
|
headers=_auth_headers(),
|
|
)).json()
|
|
sid = created["id"]
|
|
resp = await client.put(
|
|
f"/api/v1/scouts/cloud/{sid}",
|
|
json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]}
|
|
assert body["auto_trash_spam"] is True
|
|
assert body["prompt_template"] == "invoices"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_cloud_scout():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
created = (await client.post(
|
|
"/api/v1/scouts/cloud",
|
|
json={"name": "C", "provider": "gmail"},
|
|
headers=_auth_headers(),
|
|
)).json()
|
|
sid = created["id"]
|
|
resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers())
|
|
assert resp.status_code == 200
|
|
listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json()
|
|
assert all(r["id"] != sid for r in listing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gmail_labels_route_returns_labels():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
created = (await client.post(
|
|
"/api/v1/scouts/cloud",
|
|
json={"name": "L", "provider": "gmail"},
|
|
headers=_auth_headers(),
|
|
)).json()
|
|
sid = created["id"]
|
|
|
|
with patch("app.api.routes.scouts.get_connector") as mock_get:
|
|
mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}])
|
|
resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers())
|
|
assert resp.status_code == 200
|
|
assert resp.json() == [{"id": "INBOX", "name": "INBOX"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gmail_disconnect_clears_token():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
created = (await client.post(
|
|
"/api/v1/scouts/cloud",
|
|
json={"name": "D", "provider": "gmail"},
|
|
headers=_auth_headers(),
|
|
)).json()
|
|
sid = created["id"]
|
|
# mark it connected directly in the DB
|
|
async with _TestSessionLocal() as session:
|
|
row = await session.get(CloudScoutConfig, sid)
|
|
row.oauth_token_encrypted = "blob"
|
|
row.gmail_address = "a@b.com"
|
|
await session.commit()
|
|
|
|
with patch("app.api.routes.scouts.get_connector") as mock_get:
|
|
mock_get.return_value.stop_watch = AsyncMock()
|
|
resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers())
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["oauth_connected"] is False
|
|
assert body["gmail_address"] is None
|
|
assert body["enabled"] is False
|
|
|
|
|
|
# ── Pending-session create-at-finalize flow ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_draft_returns_url_and_no_scout_created():
|
|
from app.config.settings import settings as app_settings
|
|
|
|
with patch.object(app_settings, "GOOGLE_AUTH_CLIENT_ID", "cid"), \
|
|
patch.object(app_settings, "GOOGLE_AUTH_CLIENT_SECRET", "secret"):
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/scouts/oauth/gmail/authorize-draft",
|
|
json={"name": "Draft Inbox", "prompt_template": "invoices", "auto_trash_spam": True},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["authorize_url"].startswith("https://accounts.google.com/")
|
|
|
|
# No scout row should have been created by authorize-draft.
|
|
async with _TestSessionLocal() as session:
|
|
rows = (await session.execute(
|
|
select(CloudScoutConfig).where(
|
|
CloudScoutConfig.user_id == TEST_USER_IDS["power"],
|
|
CloudScoutConfig.name == "Draft Inbox",
|
|
)
|
|
)).scalars().all()
|
|
assert rows == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_creates_scout_from_session():
|
|
from app.api.routes import scouts as scouts_mod
|
|
|
|
state = "test-session-" + uuid.uuid4().hex
|
|
token = encrypt_token({"token": "x", "refresh_token": "y", "client_id": "c", "client_secret": "s"})
|
|
scouts_mod._pending_scout_oauth_states[state] = {
|
|
"code_verifier": "v",
|
|
"user_id": TEST_USER_IDS["power"],
|
|
"expires_at": time.time() + 600,
|
|
"mode": "create",
|
|
"scout_id": None,
|
|
"draft": {"name": "Finalized", "prompt_template": "tasks", "auto_trash_spam": True},
|
|
"token_encrypted": token,
|
|
"gmail_address": "me@gmail.com",
|
|
}
|
|
|
|
# Patch get_connector to raise KeyError so setup_watch is skipped (best-effort).
|
|
with patch("app.api.routes.scouts.get_connector", side_effect=KeyError("gmail")):
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/scouts/cloud/finalize",
|
|
json={"session": state, "filter_config": {"labels": ["INBOX"]}},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert body["name"] == "Finalized"
|
|
assert body["auto_trash_spam"] is True
|
|
assert body["filter_config"] == {"labels": ["INBOX"]}
|
|
assert body["gmail_address"] == "me@gmail.com"
|
|
assert body["oauth_connected"] is True
|
|
|
|
# Session must have been popped.
|
|
assert state not in scouts_mod._pending_scout_oauth_states
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_rejects_unknown_session():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/scouts/cloud/finalize",
|
|
json={"session": "does-not-exist", "filter_config": None},
|
|
headers=_auth_headers(),
|
|
)
|
|
assert resp.status_code == 401, resp.text
|