feat: initial commit from workspace-mcp
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled

This commit is contained in:
2026-03-17 19:23:33 -05:00
commit 395f0e2029
138 changed files with 41691 additions and 0 deletions

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,128 @@
from google.oauth2.credentials import Credentials
from auth.google_auth import handle_auth_callback
class _DummyFlow:
def __init__(self, credentials):
self.credentials = credentials
def fetch_token(self, authorization_response): # noqa: ARG002
return None
class _DummyOAuthStore:
def __init__(self, session_credentials=None):
self._session_credentials = session_credentials
self.stored_refresh_token = None
def validate_and_consume_oauth_state(self, state, session_id=None): # noqa: ARG002
return {"session_id": session_id, "code_verifier": "verifier"}
def get_credentials_by_mcp_session(self, mcp_session_id): # noqa: ARG002
return self._session_credentials
def store_session(self, **kwargs):
self.stored_refresh_token = kwargs.get("refresh_token")
class _DummyCredentialStore:
def __init__(self, existing_credentials=None):
self._existing_credentials = existing_credentials
self.saved_credentials = None
def get_credential(self, user_email): # noqa: ARG002
return self._existing_credentials
def store_credential(self, user_email, credentials): # noqa: ARG002
self.saved_credentials = credentials
return True
def _make_credentials(refresh_token):
return Credentials(
token="access-token",
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id="client-id",
client_secret="client-secret",
scopes=["scope.a"],
)
def test_callback_preserves_refresh_token_from_credential_store(monkeypatch):
callback_credentials = _make_credentials(refresh_token=None)
oauth_store = _DummyOAuthStore(session_credentials=None)
credential_store = _DummyCredentialStore(
existing_credentials=_make_credentials(refresh_token="file-refresh-token")
)
monkeypatch.setattr(
"auth.google_auth.create_oauth_flow",
lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005
)
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store", lambda: oauth_store
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store", lambda: credential_store
)
monkeypatch.setattr(
"auth.google_auth.get_user_info",
lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005
)
monkeypatch.setattr(
"auth.google_auth.save_credentials_to_session", lambda *args: None
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
_email, credentials = handle_auth_callback(
scopes=["scope.a"],
authorization_response="http://localhost/callback?state=abc123&code=code123",
redirect_uri="http://localhost/callback",
session_id="session-1",
)
assert credentials.refresh_token == "file-refresh-token"
assert credential_store.saved_credentials.refresh_token == "file-refresh-token"
assert oauth_store.stored_refresh_token == "file-refresh-token"
def test_callback_prefers_session_refresh_token_over_credential_store(monkeypatch):
callback_credentials = _make_credentials(refresh_token=None)
oauth_store = _DummyOAuthStore(
session_credentials=_make_credentials(refresh_token="session-refresh-token")
)
credential_store = _DummyCredentialStore(
existing_credentials=_make_credentials(refresh_token="file-refresh-token")
)
monkeypatch.setattr(
"auth.google_auth.create_oauth_flow",
lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005
)
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store", lambda: oauth_store
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store", lambda: credential_store
)
monkeypatch.setattr(
"auth.google_auth.get_user_info",
lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005
)
monkeypatch.setattr(
"auth.google_auth.save_credentials_to_session", lambda *args: None
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
_email, credentials = handle_auth_callback(
scopes=["scope.a"],
authorization_response="http://localhost/callback?state=abc123&code=code123",
redirect_uri="http://localhost/callback",
session_id="session-1",
)
assert credentials.refresh_token == "session-refresh-token"
assert credential_store.saved_credentials.refresh_token == "session-refresh-token"
assert oauth_store.stored_refresh_token == "session-refresh-token"

View File

@@ -0,0 +1,118 @@
"""Regression tests for OAuth PKCE flow wiring."""
import os
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from auth.google_auth import create_oauth_flow # noqa: E402
DUMMY_CLIENT_CONFIG = {
"web": {
"client_id": "dummy-client-id.apps.googleusercontent.com",
"client_secret": "dummy-secret",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
def test_create_oauth_flow_autogenerates_verifier_when_missing():
expected_flow = object()
with (
patch(
"auth.google_auth.load_client_secrets_from_env",
return_value=DUMMY_CLIENT_CONFIG,
),
patch(
"auth.google_auth.Flow.from_client_config",
return_value=expected_flow,
) as mock_from_client_config,
):
flow = create_oauth_flow(
scopes=["openid"],
redirect_uri="http://localhost/callback",
state="oauth-state-1",
)
assert flow is expected_flow
args, kwargs = mock_from_client_config.call_args
assert args[0] == DUMMY_CLIENT_CONFIG
assert kwargs["autogenerate_code_verifier"] is True
assert "code_verifier" not in kwargs
def test_create_oauth_flow_preserves_callback_verifier():
expected_flow = object()
with (
patch(
"auth.google_auth.load_client_secrets_from_env",
return_value=DUMMY_CLIENT_CONFIG,
),
patch(
"auth.google_auth.Flow.from_client_config",
return_value=expected_flow,
) as mock_from_client_config,
):
flow = create_oauth_flow(
scopes=["openid"],
redirect_uri="http://localhost/callback",
state="oauth-state-2",
code_verifier="saved-verifier",
)
assert flow is expected_flow
args, kwargs = mock_from_client_config.call_args
assert args[0] == DUMMY_CLIENT_CONFIG
assert kwargs["code_verifier"] == "saved-verifier"
assert kwargs["autogenerate_code_verifier"] is False
def test_create_oauth_flow_file_config_still_enables_pkce():
expected_flow = object()
with (
patch("auth.google_auth.load_client_secrets_from_env", return_value=None),
patch("auth.google_auth.os.path.exists", return_value=True),
patch(
"auth.google_auth.Flow.from_client_secrets_file",
return_value=expected_flow,
) as mock_from_file,
):
flow = create_oauth_flow(
scopes=["openid"],
redirect_uri="http://localhost/callback",
state="oauth-state-3",
)
assert flow is expected_flow
_args, kwargs = mock_from_file.call_args
assert kwargs["autogenerate_code_verifier"] is True
assert "code_verifier" not in kwargs
def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier():
expected_flow = object()
with (
patch(
"auth.google_auth.load_client_secrets_from_env",
return_value=DUMMY_CLIENT_CONFIG,
),
patch(
"auth.google_auth.Flow.from_client_config",
return_value=expected_flow,
) as mock_from_client_config,
):
flow = create_oauth_flow(
scopes=["openid"],
redirect_uri="http://localhost/callback",
state="oauth-state-4",
autogenerate_code_verifier=False,
)
assert flow is expected_flow
_args, kwargs = mock_from_client_config.call_args
assert kwargs["autogenerate_code_verifier"] is False
assert "code_verifier" not in kwargs

View File

@@ -0,0 +1,119 @@
from types import SimpleNamespace
from auth.google_auth import _determine_oauth_prompt
class _DummyCredentialStore:
def __init__(self, credentials_by_email=None):
self._credentials_by_email = credentials_by_email or {}
def get_credential(self, user_email):
return self._credentials_by_email.get(user_email)
class _DummySessionStore:
def __init__(self, user_by_session=None, credentials_by_session=None):
self._user_by_session = user_by_session or {}
self._credentials_by_session = credentials_by_session or {}
def get_user_by_mcp_session(self, mcp_session_id):
return self._user_by_session.get(mcp_session_id)
def get_credentials_by_mcp_session(self, mcp_session_id):
return self._credentials_by_session.get(mcp_session_id)
def _credentials_with_scopes(scopes):
return SimpleNamespace(scopes=scopes)
def test_prompt_select_account_when_existing_credentials_cover_scopes(monkeypatch):
required_scopes = ["scope.a", "scope.b"]
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store",
lambda: _DummySessionStore(),
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store",
lambda: _DummyCredentialStore(
{"user@gmail.com": _credentials_with_scopes(required_scopes)}
),
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
prompt = _determine_oauth_prompt(
user_google_email="user@gmail.com",
required_scopes=required_scopes,
session_id=None,
)
assert prompt == "select_account"
def test_prompt_consent_when_existing_credentials_missing_scopes(monkeypatch):
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store",
lambda: _DummySessionStore(),
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store",
lambda: _DummyCredentialStore(
{"user@gmail.com": _credentials_with_scopes(["scope.a"])}
),
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
prompt = _determine_oauth_prompt(
user_google_email="user@gmail.com",
required_scopes=["scope.a", "scope.b"],
session_id=None,
)
assert prompt == "consent"
def test_prompt_consent_when_no_existing_credentials(monkeypatch):
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store",
lambda: _DummySessionStore(),
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store",
lambda: _DummyCredentialStore(),
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
prompt = _determine_oauth_prompt(
user_google_email="new_user@gmail.com",
required_scopes=["scope.a"],
session_id=None,
)
assert prompt == "consent"
def test_prompt_uses_session_mapping_when_email_not_provided(monkeypatch):
session_id = "session-123"
required_scopes = ["scope.a"]
monkeypatch.setattr(
"auth.google_auth.get_oauth21_session_store",
lambda: _DummySessionStore(
user_by_session={session_id: "mapped@gmail.com"},
credentials_by_session={
session_id: _credentials_with_scopes(required_scopes)
},
),
)
monkeypatch.setattr(
"auth.google_auth.get_credential_store",
lambda: _DummyCredentialStore(),
)
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
prompt = _determine_oauth_prompt(
user_google_email=None,
required_scopes=required_scopes,
session_id=session_id,
)
assert prompt == "select_account"

0
tests/core/__init__.py Normal file
View File

View File

@@ -0,0 +1,69 @@
import pytest
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse
from core.server import serve_attachment
def _build_request(file_id: str) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": f"/attachments/{file_id}",
"raw_path": f"/attachments/{file_id}".encode(),
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("localhost", 8000),
"path_params": {"file_id": file_id},
}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
return Request(scope, receive)
@pytest.mark.asyncio
async def test_serve_attachment_uses_path_param_file_id(monkeypatch, tmp_path):
file_path = tmp_path / "sample.pdf"
file_path.write_bytes(b"%PDF-1.3\n")
captured = {}
class DummyStorage:
def get_attachment_metadata(self, file_id):
captured["file_id"] = file_id
return {"filename": "sample.pdf", "mime_type": "application/pdf"}
def get_attachment_path(self, _file_id):
return file_path
monkeypatch.setattr(
"core.attachment_storage.get_attachment_storage", lambda: DummyStorage()
)
response = await serve_attachment(_build_request("abc123"))
assert captured["file_id"] == "abc123"
assert isinstance(response, FileResponse)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_attachment_404_when_metadata_missing(monkeypatch):
class DummyStorage:
def get_attachment_metadata(self, _file_id):
return None
monkeypatch.setattr(
"core.attachment_storage.get_attachment_storage", lambda: DummyStorage()
)
response = await serve_attachment(_build_request("missing"))
assert isinstance(response, JSONResponse)
assert response.status_code == 404
assert b"Attachment not found or expired" in response.body

112
tests/core/test_comments.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tests for core comments module."""
import sys
import os
import pytest
from unittest.mock import Mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from core.comments import _read_comments_impl
@pytest.mark.asyncio
async def test_read_comments_includes_quoted_text():
"""Verify that quotedFileContent.value is surfaced in the output."""
mock_service = Mock()
mock_service.comments.return_value.list.return_value.execute = Mock(
return_value={
"comments": [
{
"id": "c1",
"content": "Needs a citation here.",
"author": {"displayName": "Alice"},
"createdTime": "2025-01-15T10:00:00Z",
"modifiedTime": "2025-01-15T10:00:00Z",
"resolved": False,
"quotedFileContent": {
"mimeType": "text/html",
"value": "the specific text that was highlighted",
},
"replies": [],
},
{
"id": "c2",
"content": "General comment without anchor.",
"author": {"displayName": "Bob"},
"createdTime": "2025-01-16T09:00:00Z",
"modifiedTime": "2025-01-16T09:00:00Z",
"resolved": False,
"replies": [],
},
]
}
)
result = await _read_comments_impl(mock_service, "document", "doc123")
# Comment with anchor text should show the quoted text
assert "Quoted text: the specific text that was highlighted" in result
assert "Needs a citation here." in result
# Comment without anchor text should not have a "Quoted text" line between Bob's author and content
# The output uses literal \n joins, so split on that
parts = result.split("\\n")
bob_section_started = False
for part in parts:
if "Author: Bob" in part:
bob_section_started = True
if bob_section_started and "Quoted text:" in part:
pytest.fail(
"Comment without quotedFileContent should not show 'Quoted text'"
)
if bob_section_started and "Content: General comment" in part:
break
@pytest.mark.asyncio
async def test_read_comments_empty():
"""Verify empty comments returns appropriate message."""
mock_service = Mock()
mock_service.comments.return_value.list.return_value.execute = Mock(
return_value={"comments": []}
)
result = await _read_comments_impl(mock_service, "document", "doc123")
assert "No comments found" in result
@pytest.mark.asyncio
async def test_read_comments_with_replies():
"""Verify replies are included in output."""
mock_service = Mock()
mock_service.comments.return_value.list.return_value.execute = Mock(
return_value={
"comments": [
{
"id": "c1",
"content": "Question?",
"author": {"displayName": "Alice"},
"createdTime": "2025-01-15T10:00:00Z",
"modifiedTime": "2025-01-15T10:00:00Z",
"resolved": False,
"quotedFileContent": {"value": "some text"},
"replies": [
{
"id": "r1",
"content": "Answer.",
"author": {"displayName": "Bob"},
"createdTime": "2025-01-15T11:00:00Z",
"modifiedTime": "2025-01-15T11:00:00Z",
}
],
}
]
}
)
result = await _read_comments_impl(mock_service, "document", "doc123")
assert "Question?" in result
assert "Answer." in result
assert "Bob" in result
assert "Quoted text: some text" in result

View File

@@ -0,0 +1,90 @@
import importlib
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.responses import Response
from starlette.routing import Route
from starlette.testclient import TestClient
def test_well_known_cache_control_middleware_rewrites_headers():
from core.server import WellKnownCacheControlMiddleware, _compute_scope_fingerprint
async def well_known_endpoint(request):
response = Response("ok")
response.headers["Cache-Control"] = "public, max-age=3600"
response.set_cookie("a", "1")
response.set_cookie("b", "2")
return response
async def regular_endpoint(request):
response = Response("ok")
response.headers["Cache-Control"] = "public, max-age=3600"
return response
app = Starlette(
routes=[
Route("/.well-known/oauth-authorization-server", well_known_endpoint),
Route("/.well-known/oauth-authorization-server-extra", regular_endpoint),
Route("/health", regular_endpoint),
],
middleware=[Middleware(WellKnownCacheControlMiddleware)],
)
client = TestClient(app)
well_known = client.get("/.well-known/oauth-authorization-server")
assert well_known.status_code == 200
assert well_known.headers["cache-control"] == "no-store, must-revalidate"
assert well_known.headers["etag"] == f'"{_compute_scope_fingerprint()}"'
assert sorted(well_known.headers.get_list("set-cookie")) == sorted(
["a=1; Path=/; SameSite=lax", "b=2; Path=/; SameSite=lax"]
)
regular = client.get("/health")
assert regular.status_code == 200
assert regular.headers["cache-control"] == "public, max-age=3600"
assert "etag" not in regular.headers
extra = client.get("/.well-known/oauth-authorization-server-extra")
assert extra.status_code == 200
assert extra.headers["cache-control"] == "public, max-age=3600"
assert "etag" not in extra.headers
def test_configured_server_applies_no_cache_to_served_oauth_discovery_routes(
monkeypatch,
):
monkeypatch.setenv("MCP_ENABLE_OAUTH21", "true")
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "dummy-client")
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "dummy-secret")
monkeypatch.setenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
monkeypatch.setenv("WORKSPACE_MCP_PORT", "8000")
monkeypatch.delenv("WORKSPACE_EXTERNAL_URL", raising=False)
monkeypatch.setenv("EXTERNAL_OAUTH21_PROVIDER", "false")
import core.server as core_server
from auth.oauth_config import reload_oauth_config
reload_oauth_config()
core_server = importlib.reload(core_server)
core_server.set_transport_mode("streamable-http")
core_server.configure_server_for_http()
app = core_server.server.http_app(transport="streamable-http", path="/mcp")
client = TestClient(app)
authorization_server = client.get("/.well-known/oauth-authorization-server")
assert authorization_server.status_code == 200
assert authorization_server.headers["cache-control"] == "no-store, must-revalidate"
assert authorization_server.headers["etag"].startswith('"')
assert authorization_server.headers["etag"].endswith('"')
protected_resource = client.get("/.well-known/oauth-protected-resource/mcp")
assert protected_resource.status_code == 200
assert protected_resource.headers["cache-control"] == "no-store, must-revalidate"
assert protected_resource.headers["etag"].startswith('"')
assert protected_resource.headers["etag"].endswith('"')
# Ensure we did not create a shadow route at the wrong path.
wrong_path = client.get("/.well-known/oauth-protected-resource")
assert wrong_path.status_code == 404

View File

View File

@@ -0,0 +1,373 @@
"""
Manual E2E test script for Apps Script integration.
This script tests Apps Script tools against the real Google API.
Requires valid OAuth credentials and enabled Apps Script API.
Usage:
python tests/gappsscript/manual_test.py
Environment Variables:
GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json (default: ./client_secret.json)
GOOGLE_TOKEN_PATH: Path to store OAuth token (default: ./test_token.pickle)
Note: This will create real Apps Script projects in your account.
Delete test projects manually after running.
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import pickle
SCOPES = [
"https://www.googleapis.com/auth/script.projects",
"https://www.googleapis.com/auth/script.deployments",
"https://www.googleapis.com/auth/script.processes",
"https://www.googleapis.com/auth/drive.readonly", # For listing script projects
"https://www.googleapis.com/auth/userinfo.email", # Basic user info
"openid", # Required by Google OAuth
]
# Allow http://localhost for OAuth (required for headless auth)
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
# Default paths (can be overridden via environment variables)
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
DEFAULT_CLIENT_SECRET = os.path.join(PROJECT_ROOT, "client_secret.json")
DEFAULT_TOKEN_PATH = os.path.join(PROJECT_ROOT, "test_token.pickle")
def get_credentials():
"""
Get OAuth credentials for Apps Script API.
Credential paths can be configured via environment variables:
- GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json
- GOOGLE_TOKEN_PATH: Path to store/load OAuth token
Returns:
Credentials object
"""
creds = None
token_path = os.environ.get("GOOGLE_TOKEN_PATH", DEFAULT_TOKEN_PATH)
client_secret_path = os.environ.get(
"GOOGLE_CLIENT_SECRET_PATH", DEFAULT_CLIENT_SECRET
)
if os.path.exists(token_path):
with open(token_path, "rb") as token:
creds = pickle.load(token)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(client_secret_path):
print(f"Error: {client_secret_path} not found")
print("\nTo fix this:")
print("1. Go to Google Cloud Console > APIs & Services > Credentials")
print("2. Create an OAuth 2.0 Client ID (Desktop application type)")
print("3. Download the JSON and save as client_secret.json")
print(f"\nExpected path: {client_secret_path}")
print("\nOr set GOOGLE_CLIENT_SECRET_PATH environment variable")
sys.exit(1)
flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES)
# Set redirect URI to match client_secret.json
flow.redirect_uri = "http://localhost"
# Headless flow: user copies redirect URL after auth
auth_url, _ = flow.authorization_url(prompt="consent")
print("\n" + "=" * 60)
print("HEADLESS AUTH")
print("=" * 60)
print("\n1. Open this URL in any browser:\n")
print(auth_url)
print("\n2. Sign in and authorize the app")
print("3. You'll be redirected to http://localhost (won't load)")
print("4. Copy the FULL URL from browser address bar")
print(" (looks like: http://localhost/?code=4/0A...&scope=...)")
print("5. Paste it below:\n")
redirect_response = input("Paste full redirect URL: ").strip()
flow.fetch_token(authorization_response=redirect_response)
creds = flow.credentials
with open(token_path, "wb") as token:
pickle.dump(creds, token)
return creds
async def test_list_projects(drive_service):
"""Test listing Apps Script projects using Drive API"""
print("\n=== Test: List Projects ===")
from gappsscript.apps_script_tools import _list_script_projects_impl
try:
result = await _list_script_projects_impl(
service=drive_service, user_google_email="test@example.com", page_size=10
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def test_create_project(service):
"""Test creating a new Apps Script project"""
print("\n=== Test: Create Project ===")
from gappsscript.apps_script_tools import _create_script_project_impl
try:
result = await _create_script_project_impl(
service=service,
user_google_email="test@example.com",
title="MCP Test Project",
)
print(result)
if "Script ID:" in result:
script_id = result.split("Script ID: ")[1].split("\n")[0]
return script_id
return None
except Exception as e:
print(f"Error: {e}")
return None
async def test_get_project(service, script_id):
"""Test retrieving project details"""
print(f"\n=== Test: Get Project {script_id} ===")
from gappsscript.apps_script_tools import _get_script_project_impl
try:
result = await _get_script_project_impl(
service=service, user_google_email="test@example.com", script_id=script_id
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def test_update_content(service, script_id):
"""Test updating script content"""
print(f"\n=== Test: Update Content {script_id} ===")
from gappsscript.apps_script_tools import _update_script_content_impl
files = [
{
"name": "appsscript",
"type": "JSON",
"source": """{
"timeZone": "America/New_York",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}""",
},
{
"name": "Code",
"type": "SERVER_JS",
"source": """function testFunction() {
Logger.log('Hello from MCP test!');
return 'Test successful';
}""",
},
]
try:
result = await _update_script_content_impl(
service=service,
user_google_email="test@example.com",
script_id=script_id,
files=files,
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def test_run_function(service, script_id):
"""Test running a script function"""
print(f"\n=== Test: Run Function {script_id} ===")
from gappsscript.apps_script_tools import _run_script_function_impl
try:
result = await _run_script_function_impl(
service=service,
user_google_email="test@example.com",
script_id=script_id,
function_name="testFunction",
dev_mode=True,
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def test_create_deployment(service, script_id):
"""Test creating a deployment"""
print(f"\n=== Test: Create Deployment {script_id} ===")
from gappsscript.apps_script_tools import _create_deployment_impl
try:
result = await _create_deployment_impl(
service=service,
user_google_email="test@example.com",
script_id=script_id,
description="MCP Test Deployment",
)
print(result)
if "Deployment ID:" in result:
deployment_id = result.split("Deployment ID: ")[1].split("\n")[0]
return deployment_id
return None
except Exception as e:
print(f"Error: {e}")
return None
async def test_list_deployments(service, script_id):
"""Test listing deployments"""
print(f"\n=== Test: List Deployments {script_id} ===")
from gappsscript.apps_script_tools import _list_deployments_impl
try:
result = await _list_deployments_impl(
service=service, user_google_email="test@example.com", script_id=script_id
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def test_list_processes(service):
"""Test listing script processes"""
print("\n=== Test: List Processes ===")
from gappsscript.apps_script_tools import _list_script_processes_impl
try:
result = await _list_script_processes_impl(
service=service, user_google_email="test@example.com", page_size=10
)
print(result)
return True
except Exception as e:
print(f"Error: {e}")
return False
async def cleanup_test_project(service, script_id):
"""
Cleanup test project (requires Drive API).
Note: Apps Script API does not have a delete endpoint.
Projects must be deleted via Drive API by moving to trash.
"""
print(f"\n=== Cleanup: Delete Project {script_id} ===")
print("Note: Apps Script projects must be deleted via Drive API")
print(f"Please manually delete: https://script.google.com/d/{script_id}/edit")
async def run_all_tests():
"""Run all manual tests"""
print("=" * 60)
print("Apps Script MCP Manual Test Suite")
print("=" * 60)
print("\nGetting OAuth credentials...")
creds = get_credentials()
print("Building API services...")
script_service = build("script", "v1", credentials=creds)
drive_service = build("drive", "v3", credentials=creds)
test_script_id = None
deployment_id = None
try:
success = await test_list_projects(drive_service)
if not success:
print("\nWarning: List projects failed")
test_script_id = await test_create_project(script_service)
if test_script_id:
print(f"\nCreated test project: {test_script_id}")
await test_get_project(script_service, test_script_id)
await test_update_content(script_service, test_script_id)
await asyncio.sleep(2)
await test_run_function(script_service, test_script_id)
deployment_id = await test_create_deployment(script_service, test_script_id)
if deployment_id:
print(f"\nCreated deployment: {deployment_id}")
await test_list_deployments(script_service, test_script_id)
else:
print("\nSkipping tests that require a project (creation failed)")
await test_list_processes(script_service)
finally:
if test_script_id:
await cleanup_test_project(script_service, test_script_id)
print("\n" + "=" * 60)
print("Manual Test Suite Complete")
print("=" * 60)
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(description="Manual E2E test for Apps Script")
parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompt"
)
args = parser.parse_args()
print("\nIMPORTANT: This script will:")
print("1. Create a test Apps Script project in your account")
print("2. Run various operations on it")
print("3. Leave the project for manual cleanup")
print("\nYou must manually delete the test project after running this.")
if not args.yes:
response = input("\nContinue? (yes/no): ")
if response.lower() not in ["yes", "y"]:
print("Aborted")
return
asyncio.run(run_all_tests())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,432 @@
"""
Unit tests for Google Apps Script MCP tools
Tests all Apps Script tools with mocked API responses
"""
import pytest
from unittest.mock import Mock
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
# Import the internal implementation functions (not the decorated ones)
from gappsscript.apps_script_tools import (
_list_script_projects_impl,
_get_script_project_impl,
_create_script_project_impl,
_update_script_content_impl,
_run_script_function_impl,
_create_deployment_impl,
_list_deployments_impl,
_update_deployment_impl,
_delete_deployment_impl,
_list_script_processes_impl,
_delete_script_project_impl,
_list_versions_impl,
_create_version_impl,
_get_version_impl,
_get_script_metrics_impl,
_generate_trigger_code_impl,
)
@pytest.mark.asyncio
async def test_list_script_projects():
"""Test listing Apps Script projects via Drive API"""
mock_service = Mock()
mock_response = {
"files": [
{
"id": "test123",
"name": "Test Project",
"createdTime": "2025-01-10T10:00:00Z",
"modifiedTime": "2026-01-12T15:30:00Z",
},
]
}
mock_service.files().list().execute.return_value = mock_response
result = await _list_script_projects_impl(
service=mock_service, user_google_email="test@example.com", page_size=50
)
assert "Found 1 Apps Script projects" in result
assert "Test Project" in result
assert "test123" in result
@pytest.mark.asyncio
async def test_get_script_project():
"""Test retrieving complete project details"""
mock_service = Mock()
# projects().get() returns metadata only (no files)
mock_metadata_response = {
"scriptId": "test123",
"title": "Test Project",
"creator": {"email": "creator@example.com"},
"createTime": "2025-01-10T10:00:00Z",
"updateTime": "2026-01-12T15:30:00Z",
}
# projects().getContent() returns files with source code
mock_content_response = {
"scriptId": "test123",
"files": [
{
"name": "Code",
"type": "SERVER_JS",
"source": "function test() { return 'hello'; }",
}
],
}
mock_service.projects().get().execute.return_value = mock_metadata_response
mock_service.projects().getContent().execute.return_value = mock_content_response
result = await _get_script_project_impl(
service=mock_service, user_google_email="test@example.com", script_id="test123"
)
assert "Test Project" in result
assert "creator@example.com" in result
assert "Code" in result
@pytest.mark.asyncio
async def test_create_script_project():
"""Test creating new Apps Script project"""
mock_service = Mock()
mock_response = {"scriptId": "new123", "title": "New Project"}
mock_service.projects().create().execute.return_value = mock_response
result = await _create_script_project_impl(
service=mock_service, user_google_email="test@example.com", title="New Project"
)
assert "Script ID: new123" in result
assert "New Project" in result
@pytest.mark.asyncio
async def test_update_script_content():
"""Test updating script project files"""
mock_service = Mock()
files_to_update = [
{"name": "Code", "type": "SERVER_JS", "source": "function main() {}"}
]
mock_response = {"files": files_to_update}
mock_service.projects().updateContent().execute.return_value = mock_response
result = await _update_script_content_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
files=files_to_update,
)
assert "Updated script project: test123" in result
assert "Code" in result
@pytest.mark.asyncio
async def test_run_script_function():
"""Test executing script function"""
mock_service = Mock()
mock_response = {"response": {"result": "Success"}}
mock_service.scripts().run().execute.return_value = mock_response
result = await _run_script_function_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
function_name="myFunction",
dev_mode=True,
)
assert "Execution successful" in result
assert "myFunction" in result
@pytest.mark.asyncio
async def test_create_deployment():
"""Test creating deployment"""
mock_service = Mock()
# Mock version creation (called first)
mock_version_response = {"versionNumber": 1}
mock_service.projects().versions().create().execute.return_value = (
mock_version_response
)
# Mock deployment creation (called second)
mock_deploy_response = {
"deploymentId": "deploy123",
"deploymentConfig": {},
}
mock_service.projects().deployments().create().execute.return_value = (
mock_deploy_response
)
result = await _create_deployment_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
description="Test deployment",
)
assert "Deployment ID: deploy123" in result
assert "Test deployment" in result
assert "Version: 1" in result
@pytest.mark.asyncio
async def test_list_deployments():
"""Test listing deployments"""
mock_service = Mock()
mock_response = {
"deployments": [
{
"deploymentId": "deploy123",
"description": "Production",
"updateTime": "2026-01-12T15:30:00Z",
}
]
}
mock_service.projects().deployments().list().execute.return_value = mock_response
result = await _list_deployments_impl(
service=mock_service, user_google_email="test@example.com", script_id="test123"
)
assert "Production" in result
assert "deploy123" in result
@pytest.mark.asyncio
async def test_update_deployment():
"""Test updating deployment"""
mock_service = Mock()
mock_response = {
"deploymentId": "deploy123",
"description": "Updated description",
}
mock_service.projects().deployments().update().execute.return_value = mock_response
result = await _update_deployment_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
deployment_id="deploy123",
description="Updated description",
)
assert "Updated deployment: deploy123" in result
@pytest.mark.asyncio
async def test_delete_deployment():
"""Test deleting deployment"""
mock_service = Mock()
mock_service.projects().deployments().delete().execute.return_value = {}
result = await _delete_deployment_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
deployment_id="deploy123",
)
assert "Deleted deployment: deploy123 from script: test123" in result
@pytest.mark.asyncio
async def test_list_script_processes():
"""Test listing script processes"""
mock_service = Mock()
mock_response = {
"processes": [
{
"functionName": "myFunction",
"processStatus": "COMPLETED",
"startTime": "2026-01-12T15:30:00Z",
"duration": "5s",
}
]
}
mock_service.processes().list().execute.return_value = mock_response
result = await _list_script_processes_impl(
service=mock_service, user_google_email="test@example.com", page_size=50
)
assert "myFunction" in result
assert "COMPLETED" in result
@pytest.mark.asyncio
async def test_delete_script_project():
"""Test deleting a script project"""
mock_service = Mock()
mock_service.files().delete().execute.return_value = {}
result = await _delete_script_project_impl(
service=mock_service, user_google_email="test@example.com", script_id="test123"
)
assert "Deleted Apps Script project: test123" in result
@pytest.mark.asyncio
async def test_list_versions():
"""Test listing script versions"""
mock_service = Mock()
mock_response = {
"versions": [
{
"versionNumber": 1,
"description": "Initial version",
"createTime": "2025-01-10T10:00:00Z",
},
{
"versionNumber": 2,
"description": "Bug fix",
"createTime": "2026-01-12T15:30:00Z",
},
]
}
mock_service.projects().versions().list().execute.return_value = mock_response
result = await _list_versions_impl(
service=mock_service, user_google_email="test@example.com", script_id="test123"
)
assert "Version 1" in result
assert "Initial version" in result
assert "Version 2" in result
assert "Bug fix" in result
@pytest.mark.asyncio
async def test_create_version():
"""Test creating a new version"""
mock_service = Mock()
mock_response = {
"versionNumber": 3,
"createTime": "2026-01-13T10:00:00Z",
}
mock_service.projects().versions().create().execute.return_value = mock_response
result = await _create_version_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
description="New feature",
)
assert "Created version 3" in result
assert "New feature" in result
@pytest.mark.asyncio
async def test_get_version():
"""Test getting a specific version"""
mock_service = Mock()
mock_response = {
"versionNumber": 2,
"description": "Bug fix",
"createTime": "2026-01-12T15:30:00Z",
}
mock_service.projects().versions().get().execute.return_value = mock_response
result = await _get_version_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
version_number=2,
)
assert "Version 2" in result
assert "Bug fix" in result
@pytest.mark.asyncio
async def test_get_script_metrics():
"""Test getting script metrics"""
mock_service = Mock()
mock_response = {
"activeUsers": [
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "10"}
],
"totalExecutions": [
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "100"}
],
"failedExecutions": [
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "5"}
],
}
mock_service.projects().getMetrics().execute.return_value = mock_response
result = await _get_script_metrics_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
metrics_granularity="DAILY",
)
assert "Active Users" in result
assert "10 users" in result
assert "Total Executions" in result
assert "100 executions" in result
assert "Failed Executions" in result
assert "5 failures" in result
def test_generate_trigger_code_daily():
"""Test generating daily trigger code"""
result = _generate_trigger_code_impl(
trigger_type="time_daily",
function_name="sendReport",
schedule="9",
)
assert "INSTALLABLE TRIGGER" in result
assert "createDailyTrigger_sendReport" in result
assert "everyDays(1)" in result
assert "atHour(9)" in result
def test_generate_trigger_code_on_edit():
"""Test generating onEdit trigger code"""
result = _generate_trigger_code_impl(
trigger_type="on_edit",
function_name="processEdit",
)
assert "SIMPLE TRIGGER" in result
assert "function onEdit" in result
assert "processEdit()" in result
def test_generate_trigger_code_invalid():
"""Test generating trigger code with invalid type"""
result = _generate_trigger_code_impl(
trigger_type="invalid_type",
function_name="test",
)
assert "Unknown trigger type" in result
assert "Valid types:" in result

0
tests/gchat/__init__.py Normal file
View File

View File

@@ -0,0 +1,419 @@
"""
Unit tests for Google Chat MCP tools — attachment support
"""
import base64
from urllib.parse import urlparse
import pytest
from unittest.mock import AsyncMock, Mock, patch
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
def _make_message(text="Hello", attachments=None, msg_name="spaces/S/messages/M"):
"""Build a minimal Chat API message dict for testing."""
msg = {
"name": msg_name,
"text": text,
"createTime": "2025-01-01T00:00:00Z",
"sender": {"name": "users/123", "displayName": "Test User"},
}
if attachments is not None:
msg["attachment"] = attachments
return msg
def _make_attachment(
name="spaces/S/messages/M/attachments/A",
content_name="image.png",
content_type="image/png",
resource_name="spaces/S/attachments/A",
):
att = {
"name": name,
"contentName": content_name,
"contentType": content_type,
"source": "UPLOADED_CONTENT",
}
if resource_name:
att["attachmentDataRef"] = {"resourceName": resource_name}
return att
def _unwrap(tool):
"""Unwrap a FunctionTool + decorator chain to the original async function."""
fn = getattr(tool, "fn", tool)
while hasattr(fn, "__wrapped__"):
fn = fn.__wrapped__
return fn
# ---------------------------------------------------------------------------
# get_messages: attachment metadata appears in output
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
async def test_get_messages_shows_attachment_metadata(mock_resolve):
"""When a message has attachments, get_messages should surface their metadata."""
mock_resolve.return_value = "Test User"
att = _make_attachment()
msg = _make_message(attachments=[att])
chat_service = Mock()
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
people_service = Mock()
from gchat.chat_tools import get_messages
result = await _unwrap(get_messages)(
chat_service=chat_service,
people_service=people_service,
user_google_email="test@example.com",
space_id="spaces/S",
)
assert "[attachment 0: image.png (image/png)]" in result
assert "download_chat_attachment" in result
@pytest.mark.asyncio
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
async def test_get_messages_no_attachments_unchanged(mock_resolve):
"""Messages without attachments should not include attachment lines."""
mock_resolve.return_value = "Test User"
msg = _make_message(text="Plain text message")
chat_service = Mock()
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
people_service = Mock()
from gchat.chat_tools import get_messages
result = await _unwrap(get_messages)(
chat_service=chat_service,
people_service=people_service,
user_google_email="test@example.com",
space_id="spaces/S",
)
assert "Plain text message" in result
assert "[attachment" not in result
@pytest.mark.asyncio
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
async def test_get_messages_multiple_attachments(mock_resolve):
"""Multiple attachments should each appear with their index."""
mock_resolve.return_value = "Test User"
attachments = [
_make_attachment(content_name="photo.jpg", content_type="image/jpeg"),
_make_attachment(
name="spaces/S/messages/M/attachments/B",
content_name="doc.pdf",
content_type="application/pdf",
),
]
msg = _make_message(attachments=attachments)
chat_service = Mock()
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
people_service = Mock()
from gchat.chat_tools import get_messages
result = await _unwrap(get_messages)(
chat_service=chat_service,
people_service=people_service,
user_google_email="test@example.com",
space_id="spaces/S",
)
assert "[attachment 0: photo.jpg (image/jpeg)]" in result
assert "[attachment 1: doc.pdf (application/pdf)]" in result
# ---------------------------------------------------------------------------
# search_messages: attachment indicator
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
async def test_search_messages_shows_attachment_indicator(mock_resolve):
"""search_messages should show [attachment: filename] for messages with attachments."""
mock_resolve.return_value = "Test User"
att = _make_attachment(content_name="report.pdf", content_type="application/pdf")
msg = _make_message(text="Here is the report", attachments=[att])
msg["_space_name"] = "General"
chat_service = Mock()
chat_service.spaces().list().execute.return_value = {
"spaces": [{"name": "spaces/S", "displayName": "General"}]
}
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
people_service = Mock()
from gchat.chat_tools import search_messages
result = await _unwrap(search_messages)(
chat_service=chat_service,
people_service=people_service,
user_google_email="test@example.com",
query="report",
)
assert "[attachment: report.pdf (application/pdf)]" in result
# ---------------------------------------------------------------------------
# download_chat_attachment: edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_download_no_attachments():
"""Should return a clear message when the message has no attachments."""
service = Mock()
service.spaces().messages().get().execute.return_value = _make_message()
from gchat.chat_tools import download_chat_attachment
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
)
assert "No attachments found" in result
@pytest.mark.asyncio
async def test_download_invalid_index():
"""Should return an error for out-of-range attachment_index."""
msg = _make_message(attachments=[_make_attachment()])
service = Mock()
service.spaces().messages().get().execute.return_value = msg
from gchat.chat_tools import download_chat_attachment
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
attachment_index=5,
)
assert "Invalid attachment_index" in result
assert "1 attachment(s)" in result
@pytest.mark.asyncio
async def test_download_uses_api_media_endpoint():
"""Should always use chat.googleapis.com media endpoint, not downloadUri."""
fake_bytes = b"fake image content"
att = _make_attachment()
# Even with a downloadUri present, we should use the API endpoint
att["downloadUri"] = "https://chat.google.com/api/get_attachment_url?bad=url"
msg = _make_message(attachments=[att])
service = Mock()
service.spaces().messages().get().execute.return_value = msg
service._http.credentials.token = "fake-access-token"
from gchat.chat_tools import download_chat_attachment
saved = Mock()
saved.path = "/tmp/image_abc.png"
saved.file_id = "abc"
mock_response = Mock()
mock_response.content = fake_bytes
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
patch("auth.oauth_config.is_stateless_mode", return_value=False),
patch("core.config.get_transport_mode", return_value="stdio"),
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
):
mock_get_storage.return_value.save_attachment.return_value = saved
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
attachment_index=0,
)
assert "image.png" in result
assert "/tmp/image_abc.png" in result
assert "Saved to:" in result
# Verify we used the API endpoint with attachmentDataRef.resourceName
call_args = mock_client.get.call_args
url_used = call_args.args[0]
parsed = urlparse(url_used)
assert parsed.scheme == "https"
assert parsed.hostname == "chat.googleapis.com"
assert "alt=media" in url_used
assert "spaces/S/attachments/A" in parsed.path
assert "/messages/" not in parsed.path
# Verify Bearer token
assert call_args.kwargs["headers"]["Authorization"] == "Bearer fake-access-token"
# Verify save_attachment was called with correct base64 data
save_args = mock_get_storage.return_value.save_attachment.call_args
assert save_args.kwargs["filename"] == "image.png"
assert save_args.kwargs["mime_type"] == "image/png"
decoded = base64.urlsafe_b64decode(save_args.kwargs["base64_data"])
assert decoded == fake_bytes
@pytest.mark.asyncio
async def test_download_falls_back_to_att_name():
"""When attachmentDataRef is missing, should fall back to attachment name."""
fake_bytes = b"fetched content"
att = _make_attachment(name="spaces/S/messages/M/attachments/A", resource_name=None)
msg = _make_message(attachments=[att])
service = Mock()
service.spaces().messages().get().execute.return_value = msg
service._http.credentials.token = "fake-access-token"
saved = Mock()
saved.path = "/tmp/image_fetched.png"
saved.file_id = "f1"
mock_response = Mock()
mock_response.content = fake_bytes
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
from gchat.chat_tools import download_chat_attachment
with (
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
patch("auth.oauth_config.is_stateless_mode", return_value=False),
patch("core.config.get_transport_mode", return_value="stdio"),
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
):
mock_get_storage.return_value.save_attachment.return_value = saved
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
attachment_index=0,
)
assert "image.png" in result
assert "/tmp/image_fetched.png" in result
# Falls back to attachment name when no attachmentDataRef
call_args = mock_client.get.call_args
assert "spaces/S/messages/M/attachments/A" in call_args.args[0]
@pytest.mark.asyncio
async def test_download_http_mode_returns_url():
"""In HTTP mode, should return a download URL instead of file path."""
fake_bytes = b"image data"
att = _make_attachment()
msg = _make_message(attachments=[att])
service = Mock()
service.spaces().messages().get().execute.return_value = msg
service._http.credentials.token = "fake-token"
mock_response = Mock()
mock_response.content = fake_bytes
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
saved = Mock()
saved.path = "/tmp/image_alt.png"
saved.file_id = "alt1"
from gchat.chat_tools import download_chat_attachment
with (
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
patch("auth.oauth_config.is_stateless_mode", return_value=False),
patch("core.config.get_transport_mode", return_value="http"),
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
patch(
"core.attachment_storage.get_attachment_url",
return_value="http://localhost:8005/attachments/alt1",
),
):
mock_get_storage.return_value.save_attachment.return_value = saved
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
attachment_index=0,
)
assert "Download URL:" in result
assert "expire after 1 hour" in result
@pytest.mark.asyncio
async def test_download_returns_error_on_failure():
"""When download fails, should return a clear error message."""
att = _make_attachment()
att["downloadUri"] = "https://storage.googleapis.com/fake?alt=media"
msg = _make_message(attachments=[att])
service = Mock()
service.spaces().messages().get().execute.return_value = msg
service._http.credentials.token = "fake-token"
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("connection refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
from gchat.chat_tools import download_chat_attachment
with patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client):
result = await _unwrap(download_chat_attachment)(
service=service,
user_google_email="test@example.com",
message_id="spaces/S/messages/M",
attachment_index=0,
)
assert "Failed to download" in result
assert "connection refused" in result

View File

@@ -0,0 +1 @@
# Tests for Google Contacts tools

View File

@@ -0,0 +1,339 @@
"""
Unit tests for Google Contacts (People API) tools.
Tests helper functions and formatting utilities.
"""
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gcontacts.contacts_tools import (
_format_contact,
_build_person_body,
)
class TestFormatContact:
"""Tests for _format_contact helper function."""
def test_format_basic_contact(self):
"""Test formatting a contact with basic fields."""
person = {
"resourceName": "people/c1234567890",
"names": [{"displayName": "John Doe"}],
"emailAddresses": [{"value": "john@example.com"}],
"phoneNumbers": [{"value": "+1234567890"}],
}
result = _format_contact(person)
assert "Contact ID: c1234567890" in result
assert "Name: John Doe" in result
assert "Email: john@example.com" in result
assert "Phone: +1234567890" in result
def test_format_contact_with_organization(self):
"""Test formatting a contact with organization info."""
person = {
"resourceName": "people/c123",
"names": [{"displayName": "Jane Smith"}],
"organizations": [{"name": "Acme Corp", "title": "Engineer"}],
}
result = _format_contact(person)
assert "Name: Jane Smith" in result
assert "Organization: Engineer at Acme Corp" in result
def test_format_contact_organization_name_only(self):
"""Test formatting a contact with only organization name."""
person = {
"resourceName": "people/c123",
"organizations": [{"name": "Acme Corp"}],
}
result = _format_contact(person)
assert "Organization: at Acme Corp" in result
def test_format_contact_job_title_only(self):
"""Test formatting a contact with only job title."""
person = {
"resourceName": "people/c123",
"organizations": [{"title": "CEO"}],
}
result = _format_contact(person)
assert "Organization: CEO" in result
def test_format_contact_detailed(self):
"""Test formatting a contact with detailed fields."""
person = {
"resourceName": "people/c123",
"names": [{"displayName": "Test User"}],
"addresses": [{"formattedValue": "123 Main St, City"}],
"birthdays": [{"date": {"year": 1990, "month": 5, "day": 15}}],
"urls": [{"value": "https://example.com"}],
"biographies": [{"value": "A short bio"}],
"metadata": {"sources": [{"type": "CONTACT"}]},
}
result = _format_contact(person, detailed=True)
assert "Address: 123 Main St, City" in result
assert "Birthday: 1990/5/15" in result
assert "URLs: https://example.com" in result
assert "Notes: A short bio" in result
assert "Sources: CONTACT" in result
def test_format_contact_detailed_birthday_without_year(self):
"""Test formatting birthday without year."""
person = {
"resourceName": "people/c123",
"birthdays": [{"date": {"month": 5, "day": 15}}],
}
result = _format_contact(person, detailed=True)
assert "Birthday: 5/15" in result
def test_format_contact_detailed_long_biography(self):
"""Test formatting truncates long biographies."""
long_bio = "A" * 300
person = {
"resourceName": "people/c123",
"biographies": [{"value": long_bio}],
}
result = _format_contact(person, detailed=True)
assert "Notes:" in result
assert "..." in result
assert len(result.split("Notes: ")[1].split("\n")[0]) <= 203 # 200 + "..."
def test_format_contact_empty(self):
"""Test formatting a contact with minimal fields."""
person = {"resourceName": "people/c999"}
result = _format_contact(person)
assert "Contact ID: c999" in result
def test_format_contact_unknown_resource(self):
"""Test formatting a contact without resourceName."""
person = {}
result = _format_contact(person)
assert "Contact ID: Unknown" in result
def test_format_contact_multiple_emails(self):
"""Test formatting a contact with multiple emails."""
person = {
"resourceName": "people/c123",
"emailAddresses": [
{"value": "work@example.com"},
{"value": "personal@example.com"},
],
}
result = _format_contact(person)
assert "work@example.com" in result
assert "personal@example.com" in result
def test_format_contact_multiple_phones(self):
"""Test formatting a contact with multiple phone numbers."""
person = {
"resourceName": "people/c123",
"phoneNumbers": [
{"value": "+1111111111"},
{"value": "+2222222222"},
],
}
result = _format_contact(person)
assert "+1111111111" in result
assert "+2222222222" in result
def test_format_contact_multiple_urls(self):
"""Test formatting a contact with multiple URLs."""
person = {
"resourceName": "people/c123",
"urls": [
{"value": "https://linkedin.com/user"},
{"value": "https://twitter.com/user"},
],
}
result = _format_contact(person, detailed=True)
assert "https://linkedin.com/user" in result
assert "https://twitter.com/user" in result
class TestBuildPersonBody:
"""Tests for _build_person_body helper function."""
def test_build_basic_body(self):
"""Test building a basic person body."""
body = _build_person_body(
given_name="John",
family_name="Doe",
email="john@example.com",
)
assert body["names"][0]["givenName"] == "John"
assert body["names"][0]["familyName"] == "Doe"
assert body["emailAddresses"][0]["value"] == "john@example.com"
def test_build_body_with_phone(self):
"""Test building a person body with phone."""
body = _build_person_body(phone="+1234567890")
assert body["phoneNumbers"][0]["value"] == "+1234567890"
def test_build_body_with_organization(self):
"""Test building a person body with organization."""
body = _build_person_body(
given_name="Jane",
organization="Acme Corp",
job_title="Engineer",
)
assert body["names"][0]["givenName"] == "Jane"
assert body["organizations"][0]["name"] == "Acme Corp"
assert body["organizations"][0]["title"] == "Engineer"
def test_build_body_organization_only(self):
"""Test building a person body with only organization name."""
body = _build_person_body(organization="Acme Corp")
assert body["organizations"][0]["name"] == "Acme Corp"
assert "title" not in body["organizations"][0]
def test_build_body_job_title_only(self):
"""Test building a person body with only job title."""
body = _build_person_body(job_title="CEO")
assert body["organizations"][0]["title"] == "CEO"
assert "name" not in body["organizations"][0]
def test_build_body_with_notes(self):
"""Test building a person body with notes."""
body = _build_person_body(notes="Important contact")
assert body["biographies"][0]["value"] == "Important contact"
assert body["biographies"][0]["contentType"] == "TEXT_PLAIN"
def test_build_body_with_address(self):
"""Test building a person body with address."""
body = _build_person_body(address="123 Main St, City, State 12345")
assert (
body["addresses"][0]["formattedValue"] == "123 Main St, City, State 12345"
)
def test_build_empty_body(self):
"""Test building an empty person body."""
body = _build_person_body()
assert body == {}
def test_build_body_given_name_only(self):
"""Test building a person body with only given name."""
body = _build_person_body(given_name="John")
assert body["names"][0]["givenName"] == "John"
assert body["names"][0]["familyName"] == ""
def test_build_body_family_name_only(self):
"""Test building a person body with only family name."""
body = _build_person_body(family_name="Doe")
assert body["names"][0]["givenName"] == ""
assert body["names"][0]["familyName"] == "Doe"
def test_build_full_body(self):
"""Test building a person body with all fields."""
body = _build_person_body(
given_name="John",
family_name="Doe",
email="john@example.com",
phone="+1234567890",
organization="Acme Corp",
job_title="Engineer",
notes="VIP contact",
address="123 Main St",
)
assert body["names"][0]["givenName"] == "John"
assert body["names"][0]["familyName"] == "Doe"
assert body["emailAddresses"][0]["value"] == "john@example.com"
assert body["phoneNumbers"][0]["value"] == "+1234567890"
assert body["organizations"][0]["name"] == "Acme Corp"
assert body["organizations"][0]["title"] == "Engineer"
assert body["biographies"][0]["value"] == "VIP contact"
assert body["addresses"][0]["formattedValue"] == "123 Main St"
class TestImports:
"""Tests to verify module imports work correctly."""
def test_import_contacts_tools(self):
"""Test that contacts_tools module can be imported."""
from gcontacts import contacts_tools
assert hasattr(contacts_tools, "list_contacts")
assert hasattr(contacts_tools, "get_contact")
assert hasattr(contacts_tools, "search_contacts")
assert hasattr(contacts_tools, "manage_contact")
def test_import_group_tools(self):
"""Test that group tools can be imported."""
from gcontacts import contacts_tools
assert hasattr(contacts_tools, "list_contact_groups")
assert hasattr(contacts_tools, "get_contact_group")
assert hasattr(contacts_tools, "manage_contact_group")
def test_import_batch_tools(self):
"""Test that batch tools can be imported."""
from gcontacts import contacts_tools
assert hasattr(contacts_tools, "manage_contacts_batch")
class TestConstants:
"""Tests for module constants."""
def test_default_person_fields(self):
"""Test default person fields constant."""
from gcontacts.contacts_tools import DEFAULT_PERSON_FIELDS
assert "names" in DEFAULT_PERSON_FIELDS
assert "emailAddresses" in DEFAULT_PERSON_FIELDS
assert "phoneNumbers" in DEFAULT_PERSON_FIELDS
assert "organizations" in DEFAULT_PERSON_FIELDS
def test_detailed_person_fields(self):
"""Test detailed person fields constant."""
from gcontacts.contacts_tools import DETAILED_PERSON_FIELDS
assert "names" in DETAILED_PERSON_FIELDS
assert "emailAddresses" in DETAILED_PERSON_FIELDS
assert "addresses" in DETAILED_PERSON_FIELDS
assert "birthdays" in DETAILED_PERSON_FIELDS
assert "biographies" in DETAILED_PERSON_FIELDS
def test_contact_group_fields(self):
"""Test contact group fields constant."""
from gcontacts.contacts_tools import CONTACT_GROUP_FIELDS
assert "name" in CONTACT_GROUP_FIELDS
assert "groupType" in CONTACT_GROUP_FIELDS
assert "memberCount" in CONTACT_GROUP_FIELDS

0
tests/gdocs/__init__.py Normal file
View File

View File

@@ -0,0 +1,455 @@
"""Tests for the Google Docs to Markdown converter."""
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gdocs.docs_markdown import (
convert_doc_to_markdown,
format_comments_appendix,
format_comments_inline,
parse_drive_comments,
)
# --- Fixtures ---
SIMPLE_DOC = {
"title": "Simple Test",
"body": {
"content": [
{"sectionBreak": {"sectionStyle": {}}},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Hello world\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
}
},
{
"paragraph": {
"elements": [
{"textRun": {"content": "This is ", "textStyle": {}}},
{"textRun": {"content": "bold", "textStyle": {"bold": True}}},
{"textRun": {"content": " and ", "textStyle": {}}},
{
"textRun": {
"content": "italic",
"textStyle": {"italic": True},
}
},
{"textRun": {"content": " text.\n", "textStyle": {}}},
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
}
},
]
},
}
HEADINGS_DOC = {
"title": "Headings",
"body": {
"content": [
{"sectionBreak": {"sectionStyle": {}}},
{
"paragraph": {
"elements": [{"textRun": {"content": "Title\n", "textStyle": {}}}],
"paragraphStyle": {"namedStyleType": "TITLE"},
}
},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Heading one\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "HEADING_1"},
}
},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Heading two\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "HEADING_2"},
}
},
]
},
}
TABLE_DOC = {
"title": "Table Test",
"body": {
"content": [
{"sectionBreak": {"sectionStyle": {}}},
{
"table": {
"rows": 2,
"columns": 2,
"tableRows": [
{
"tableCells": [
{
"content": [
{
"paragraph": {
"elements": [
{
"textRun": {
"content": "Name\n",
"textStyle": {},
}
}
],
"paragraphStyle": {
"namedStyleType": "NORMAL_TEXT"
},
}
}
]
},
{
"content": [
{
"paragraph": {
"elements": [
{
"textRun": {
"content": "Age\n",
"textStyle": {},
}
}
],
"paragraphStyle": {
"namedStyleType": "NORMAL_TEXT"
},
}
}
]
},
]
},
{
"tableCells": [
{
"content": [
{
"paragraph": {
"elements": [
{
"textRun": {
"content": "Alice\n",
"textStyle": {},
}
}
],
"paragraphStyle": {
"namedStyleType": "NORMAL_TEXT"
},
}
}
]
},
{
"content": [
{
"paragraph": {
"elements": [
{
"textRun": {
"content": "30\n",
"textStyle": {},
}
}
],
"paragraphStyle": {
"namedStyleType": "NORMAL_TEXT"
},
}
}
]
},
]
},
],
}
},
]
},
}
LIST_DOC = {
"title": "List Test",
"lists": {
"kix.list001": {
"listProperties": {
"nestingLevels": [
{"glyphType": "GLYPH_TYPE_UNSPECIFIED", "glyphSymbol": "\u2022"},
]
}
}
},
"body": {
"content": [
{"sectionBreak": {"sectionStyle": {}}},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Item one\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
}
},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Item two\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
}
},
]
},
}
# --- Converter tests ---
class TestTextFormatting:
def test_plain_text(self):
md = convert_doc_to_markdown(SIMPLE_DOC)
assert "Hello world" in md
def test_bold(self):
md = convert_doc_to_markdown(SIMPLE_DOC)
assert "**bold**" in md
def test_italic(self):
md = convert_doc_to_markdown(SIMPLE_DOC)
assert "*italic*" in md
class TestHeadings:
def test_title(self):
md = convert_doc_to_markdown(HEADINGS_DOC)
assert "# Title" in md
def test_h1(self):
md = convert_doc_to_markdown(HEADINGS_DOC)
assert "# Heading one" in md
def test_h2(self):
md = convert_doc_to_markdown(HEADINGS_DOC)
assert "## Heading two" in md
class TestTables:
def test_table_header(self):
md = convert_doc_to_markdown(TABLE_DOC)
assert "| Name | Age |" in md
def test_table_separator(self):
md = convert_doc_to_markdown(TABLE_DOC)
assert "| --- | --- |" in md
def test_table_row(self):
md = convert_doc_to_markdown(TABLE_DOC)
assert "| Alice | 30 |" in md
class TestLists:
def test_unordered(self):
md = convert_doc_to_markdown(LIST_DOC)
assert "- Item one" in md
assert "- Item two" in md
CHECKLIST_DOC = {
"title": "Checklist Test",
"lists": {
"kix.checklist001": {
"listProperties": {
"nestingLevels": [
{"glyphType": "GLYPH_TYPE_UNSPECIFIED"},
]
}
}
},
"body": {
"content": [
{"sectionBreak": {"sectionStyle": {}}},
{
"paragraph": {
"elements": [
{"textRun": {"content": "Buy groceries\n", "textStyle": {}}}
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
}
},
{
"paragraph": {
"elements": [
{
"textRun": {
"content": "Walk the dog\n",
"textStyle": {"strikethrough": True},
}
}
],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
}
},
]
},
}
class TestChecklists:
def test_unchecked(self):
md = convert_doc_to_markdown(CHECKLIST_DOC)
assert "- [ ] Buy groceries" in md
def test_checked(self):
md = convert_doc_to_markdown(CHECKLIST_DOC)
assert "- [x] Walk the dog" in md
def test_checked_no_strikethrough(self):
"""Checked items should not have redundant ~~strikethrough~~ markdown."""
md = convert_doc_to_markdown(CHECKLIST_DOC)
assert "~~Walk the dog~~" not in md
def test_regular_bullet_not_checklist(self):
"""Bullet lists with glyphSymbol should remain as plain bullets."""
md = convert_doc_to_markdown(LIST_DOC)
assert "[ ]" not in md
assert "[x]" not in md
class TestEmptyDoc:
def test_empty(self):
md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}})
assert md.strip() == ""
# --- Comment parsing tests ---
class TestParseComments:
def test_filters_resolved(self):
response = {
"comments": [
{
"content": "open",
"resolved": False,
"author": {"displayName": "A"},
"replies": [],
},
{
"content": "closed",
"resolved": True,
"author": {"displayName": "B"},
"replies": [],
},
]
}
result = parse_drive_comments(response, include_resolved=False)
assert len(result) == 1
assert result[0]["content"] == "open"
def test_includes_resolved(self):
response = {
"comments": [
{
"content": "open",
"resolved": False,
"author": {"displayName": "A"},
"replies": [],
},
{
"content": "closed",
"resolved": True,
"author": {"displayName": "B"},
"replies": [],
},
]
}
result = parse_drive_comments(response, include_resolved=True)
assert len(result) == 2
def test_anchor_text(self):
response = {
"comments": [
{
"content": "note",
"resolved": False,
"author": {"displayName": "A"},
"quotedFileContent": {"value": "highlighted text"},
"replies": [],
}
]
}
result = parse_drive_comments(response)
assert result[0]["anchor_text"] == "highlighted text"
# --- Comment formatting tests ---
class TestInlineComments:
def test_inserts_footnote(self):
md = "Some text here."
comments = [
{
"author": "Alice",
"content": "Note.",
"anchor_text": "text",
"replies": [],
"resolved": False,
}
]
result = format_comments_inline(md, comments)
assert "text[^c1]" in result
assert "[^c1]: **Alice**: Note." in result
def test_unmatched_goes_to_appendix(self):
md = "No match."
comments = [
{
"author": "Alice",
"content": "Note.",
"anchor_text": "missing",
"replies": [],
"resolved": False,
}
]
result = format_comments_inline(md, comments)
assert "## Comments" in result
assert "> missing" in result
class TestAppendixComments:
def test_structure(self):
comments = [
{
"author": "Alice",
"content": "Note.",
"anchor_text": "some text",
"replies": [],
"resolved": False,
}
]
result = format_comments_appendix(comments)
assert "## Comments" in result
assert "> some text" in result
assert "**Alice**: Note." in result
def test_empty(self):
assert format_comments_appendix([]).strip() == ""

View File

@@ -0,0 +1,139 @@
"""
Tests for update_paragraph_style batch operation support.
Covers the helpers, validation, and batch manager integration.
"""
import pytest
from unittest.mock import AsyncMock, Mock
from gdocs.docs_helpers import (
build_paragraph_style,
create_update_paragraph_style_request,
)
from gdocs.managers.validation_manager import ValidationManager
class TestBuildParagraphStyle:
def test_no_params_returns_empty(self):
style, fields = build_paragraph_style()
assert style == {}
assert fields == []
def test_heading_zero_maps_to_normal_text(self):
style, fields = build_paragraph_style(heading_level=0)
assert style["namedStyleType"] == "NORMAL_TEXT"
def test_heading_maps_to_named_style(self):
style, _ = build_paragraph_style(heading_level=3)
assert style["namedStyleType"] == "HEADING_3"
def test_heading_out_of_range_raises(self):
with pytest.raises(ValueError):
build_paragraph_style(heading_level=7)
def test_line_spacing_scaled_to_percentage(self):
style, _ = build_paragraph_style(line_spacing=1.5)
assert style["lineSpacing"] == 150.0
def test_dimension_field_uses_pt_unit(self):
style, _ = build_paragraph_style(indent_start=36.0)
assert style["indentStart"] == {"magnitude": 36.0, "unit": "PT"}
def test_multiple_params_combined(self):
style, fields = build_paragraph_style(
heading_level=2, alignment="CENTER", space_below=12.0
)
assert len(fields) == 3
assert style["alignment"] == "CENTER"
class TestCreateUpdateParagraphStyleRequest:
def test_returns_none_when_no_styles(self):
assert create_update_paragraph_style_request(1, 10) is None
def test_produces_correct_api_structure(self):
result = create_update_paragraph_style_request(1, 10, heading_level=1)
inner = result["updateParagraphStyle"]
assert inner["range"] == {"startIndex": 1, "endIndex": 10}
assert inner["paragraphStyle"]["namedStyleType"] == "HEADING_1"
assert inner["fields"] == "namedStyleType"
class TestValidateParagraphStyleParams:
@pytest.fixture()
def vm(self):
return ValidationManager()
def test_all_none_rejected(self, vm):
is_valid, _ = vm.validate_paragraph_style_params()
assert not is_valid
def test_wrong_types_rejected(self, vm):
assert not vm.validate_paragraph_style_params(heading_level=1.5)[0]
assert not vm.validate_paragraph_style_params(alignment=123)[0]
assert not vm.validate_paragraph_style_params(line_spacing="double")[0]
def test_negative_indent_start_rejected(self, vm):
is_valid, msg = vm.validate_paragraph_style_params(indent_start=-5.0)
assert not is_valid
assert "non-negative" in msg
def test_negative_indent_first_line_allowed(self, vm):
"""Hanging indent requires negative first-line indent."""
assert vm.validate_paragraph_style_params(indent_first_line=-18.0)[0]
def test_batch_validation_wired_up(self, vm):
valid_ops = [
{
"type": "update_paragraph_style",
"start_index": 1,
"end_index": 20,
"heading_level": 2,
},
]
assert vm.validate_batch_operations(valid_ops)[0]
no_style_ops = [
{"type": "update_paragraph_style", "start_index": 1, "end_index": 20},
]
assert not vm.validate_batch_operations(no_style_ops)[0]
class TestBatchManagerIntegration:
@pytest.fixture()
def manager(self):
from gdocs.managers.batch_operation_manager import BatchOperationManager
return BatchOperationManager(Mock())
def test_build_request_and_description(self, manager):
op = {
"type": "update_paragraph_style",
"start_index": 1,
"end_index": 50,
"heading_level": 2,
"alignment": "CENTER",
"line_spacing": 1.5,
}
request, desc = manager._build_operation_request(op, "update_paragraph_style")
assert "updateParagraphStyle" in request
assert "heading: H2" in desc
assert "1.5x" in desc
@pytest.mark.asyncio
async def test_end_to_end_execute(self, manager):
manager._execute_batch_requests = AsyncMock(return_value={"replies": [{}]})
success, message, meta = await manager.execute_batch_operations(
"doc-123",
[
{
"type": "update_paragraph_style",
"start_index": 1,
"end_index": 20,
"heading_level": 1,
}
],
)
assert success
assert meta["operations_count"] == 1

1
tests/gdrive/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,147 @@
"""
Unit tests for create_drive_folder tool.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gdrive.drive_tools import _create_drive_folder_impl as _raw_create_drive_folder
def _make_service(created_response):
"""Build a mock Drive service whose files().create().execute returns *created_response*."""
execute = MagicMock(return_value=created_response)
create = MagicMock()
create.return_value.execute = execute
files = MagicMock()
files.return_value.create = create
service = MagicMock()
service.files = files
return service
@pytest.mark.asyncio
async def test_create_folder_root_skips_resolve():
"""Parent 'root' should pass through resolve_folder_id and produce correct output."""
api_response = {
"id": "new-folder-id",
"name": "My Folder",
"webViewLink": "https://drive.google.com/drive/folders/new-folder-id",
}
service = _make_service(api_response)
with patch(
"gdrive.drive_tools.resolve_folder_id",
new_callable=AsyncMock,
return_value="root",
):
result = await _raw_create_drive_folder(
service,
user_google_email="user@example.com",
folder_name="My Folder",
parent_folder_id="root",
)
assert "new-folder-id" in result
assert "My Folder" in result
assert "https://drive.google.com/drive/folders/new-folder-id" in result
@pytest.mark.asyncio
async def test_create_folder_custom_parent_resolves():
"""A non-root parent_folder_id should go through resolve_folder_id."""
api_response = {
"id": "new-folder-id",
"name": "Sub Folder",
"webViewLink": "https://drive.google.com/drive/folders/new-folder-id",
}
service = _make_service(api_response)
with patch(
"gdrive.drive_tools.resolve_folder_id",
new_callable=AsyncMock,
return_value="resolved-parent-id",
) as mock_resolve:
result = await _raw_create_drive_folder(
service,
user_google_email="user@example.com",
folder_name="Sub Folder",
parent_folder_id="shortcut-id",
)
mock_resolve.assert_awaited_once_with(service, "shortcut-id")
# The output message uses the original parent_folder_id, not the resolved one
assert "shortcut-id" in result
# But the API call should use the resolved ID
service.files().create.assert_called_once_with(
body={
"name": "Sub Folder",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["resolved-parent-id"],
},
fields="id, name, webViewLink",
supportsAllDrives=True,
)
@pytest.mark.asyncio
async def test_create_folder_passes_correct_metadata():
"""Verify the metadata dict sent to the Drive API is correct."""
api_response = {
"id": "abc123",
"name": "Test",
"webViewLink": "https://drive.google.com/drive/folders/abc123",
}
service = _make_service(api_response)
with patch(
"gdrive.drive_tools.resolve_folder_id",
new_callable=AsyncMock,
return_value="resolved-id",
):
await _raw_create_drive_folder(
service,
user_google_email="user@example.com",
folder_name="Test",
parent_folder_id="some-parent",
)
service.files().create.assert_called_once_with(
body={
"name": "Test",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["resolved-id"],
},
fields="id, name, webViewLink",
supportsAllDrives=True,
)
@pytest.mark.asyncio
async def test_create_folder_missing_webviewlink():
"""When the API omits webViewLink, the result should have an empty link."""
api_response = {
"id": "abc123",
"name": "NoLink",
}
service = _make_service(api_response)
with patch(
"gdrive.drive_tools.resolve_folder_id",
new_callable=AsyncMock,
return_value="root",
):
result = await _raw_create_drive_folder(
service,
user_google_email="user@example.com",
folder_name="NoLink",
parent_folder_id="root",
)
assert "abc123" in result
assert "NoLink" in result

View File

@@ -0,0 +1,970 @@
"""
Unit tests for Google Drive MCP tools.
Tests create_drive_folder with mocked API responses, plus coverage for
`search_drive_files` and `list_drive_items` pagination, `detailed` output,
and `file_type` filtering behaviors.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gdrive.drive_helpers import build_drive_list_params
from gdrive.drive_tools import list_drive_items, search_drive_files
def _unwrap(tool):
"""Unwrap a FunctionTool + decorator chain to the original async function.
Handles both older FastMCP (FunctionTool with .fn) and newer FastMCP
(server.tool() returns the function directly).
"""
fn = tool.fn if hasattr(tool, "fn") else tool
while hasattr(fn, "__wrapped__"):
fn = fn.__wrapped__
return fn
# ---------------------------------------------------------------------------
# search_drive_files — page_token
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_drive_files_page_token_passed_to_api():
"""page_token is forwarded to the Drive API as pageToken."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "f1",
"name": "Report.pdf",
"mimeType": "application/pdf",
"webViewLink": "https://drive.google.com/file/f1",
"modifiedTime": "2024-01-01T00:00:00Z",
}
]
}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="budget",
page_token="tok_abc123",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert call_kwargs.get("pageToken") == "tok_abc123"
@pytest.mark.asyncio
async def test_search_drive_files_next_page_token_in_output():
"""nextPageToken from the API response is appended at the end of the output."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "f2",
"name": "Notes.docx",
"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"webViewLink": "https://drive.google.com/file/f2",
"modifiedTime": "2024-02-01T00:00:00Z",
}
],
"nextPageToken": "next_tok_xyz",
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="notes",
)
assert result.endswith("nextPageToken: next_tok_xyz")
@pytest.mark.asyncio
async def test_search_drive_files_no_next_page_token_when_absent():
"""nextPageToken does not appear in output when the API has no more pages."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "f3",
"name": "Summary.txt",
"mimeType": "text/plain",
"webViewLink": "https://drive.google.com/file/f3",
"modifiedTime": "2024-03-01T00:00:00Z",
}
]
# no nextPageToken key
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="summary",
)
assert "nextPageToken" not in result
# ---------------------------------------------------------------------------
# list_drive_items — page_token
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_drive_items_page_token_passed_to_api(mock_resolve_folder):
"""page_token is forwarded to the Drive API as pageToken."""
mock_resolve_folder.return_value = "root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "folder1",
"name": "Archive",
"mimeType": "application/vnd.google-apps.folder",
"webViewLink": "https://drive.google.com/drive/folders/folder1",
"modifiedTime": "2024-01-15T00:00:00Z",
}
]
}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
page_token="tok_page2",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert call_kwargs.get("pageToken") == "tok_page2"
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder):
"""nextPageToken from the API response is appended at the end of the output."""
mock_resolve_folder.return_value = "root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "file99",
"name": "data.csv",
"mimeType": "text/csv",
"webViewLink": "https://drive.google.com/file/file99",
"modifiedTime": "2024-04-01T00:00:00Z",
}
],
"nextPageToken": "next_list_tok",
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
)
assert result.endswith("nextPageToken: next_list_tok")
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_drive_items_no_next_page_token_when_absent(mock_resolve_folder):
"""nextPageToken does not appear in output when the API has no more pages."""
mock_resolve_folder.return_value = "root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
{
"id": "file100",
"name": "readme.txt",
"mimeType": "text/plain",
"webViewLink": "https://drive.google.com/file/file100",
"modifiedTime": "2024-05-01T00:00:00Z",
}
]
# no nextPageToken key
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
)
assert "nextPageToken" not in result
# Helpers
# ---------------------------------------------------------------------------
def _make_file(
file_id: str,
name: str,
mime_type: str,
link: str = "http://link",
modified: str = "2024-01-01T00:00:00Z",
size: str | None = None,
) -> dict:
item = {
"id": file_id,
"name": name,
"mimeType": mime_type,
"webViewLink": link,
"modifiedTime": modified,
}
if size is not None:
item["size"] = size
return item
# ---------------------------------------------------------------------------
# create_drive_folder
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_drive_folder():
"""Test create_drive_folder returns success message with folder id, name, and link."""
from gdrive.drive_tools import _create_drive_folder_impl
mock_service = Mock()
mock_response = {
"id": "folder123",
"name": "My Folder",
"webViewLink": "https://drive.google.com/drive/folders/folder123",
}
mock_request = Mock()
mock_request.execute.return_value = mock_response
mock_service.files.return_value.create.return_value = mock_request
with patch(
"gdrive.drive_tools.resolve_folder_id",
new_callable=AsyncMock,
return_value="root",
):
result = await _create_drive_folder_impl(
service=mock_service,
user_google_email="user@example.com",
folder_name="My Folder",
parent_folder_id="root",
)
assert "Successfully created folder" in result
assert "My Folder" in result
assert "folder123" in result
assert "user@example.com" in result
assert "https://drive.google.com/drive/folders/folder123" in result
# ---------------------------------------------------------------------------
# build_drive_list_params — detailed flag (pure unit tests, no I/O)
# ---------------------------------------------------------------------------
def test_build_params_detailed_true_includes_extra_fields():
"""detailed=True requests modifiedTime, webViewLink, and size from the API."""
params = build_drive_list_params(query="name='x'", page_size=10, detailed=True)
assert "modifiedTime" in params["fields"]
assert "webViewLink" in params["fields"]
assert "size" in params["fields"]
def test_build_params_detailed_false_omits_extra_fields():
"""detailed=False omits modifiedTime, webViewLink, and size from the API request."""
params = build_drive_list_params(query="name='x'", page_size=10, detailed=False)
assert "modifiedTime" not in params["fields"]
assert "webViewLink" not in params["fields"]
assert "size" not in params["fields"]
def test_build_params_detailed_false_keeps_core_fields():
"""detailed=False still requests id, name, and mimeType."""
params = build_drive_list_params(query="name='x'", page_size=10, detailed=False)
assert "id" in params["fields"]
assert "name" in params["fields"]
assert "mimeType" in params["fields"]
def test_build_params_default_is_detailed():
"""Omitting detailed behaves identically to detailed=True."""
params_default = build_drive_list_params(query="q", page_size=5)
params_true = build_drive_list_params(query="q", page_size=5, detailed=True)
assert params_default["fields"] == params_true["fields"]
# ---------------------------------------------------------------------------
# search_drive_files — detailed flag
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_detailed_true_output_includes_metadata():
"""detailed=True (default) includes modified time and link in output."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file(
"f1",
"My Doc",
"application/vnd.google-apps.document",
modified="2024-06-01T12:00:00Z",
link="http://link/f1",
)
]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="my doc",
detailed=True,
)
assert "My Doc" in result
assert "2024-06-01T12:00:00Z" in result
assert "http://link/f1" in result
@pytest.mark.asyncio
async def test_search_detailed_false_output_excludes_metadata():
"""detailed=False omits modified time and link from output."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file(
"f1",
"My Doc",
"application/vnd.google-apps.document",
modified="2024-06-01T12:00:00Z",
link="http://link/f1",
)
]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="my doc",
detailed=False,
)
assert "My Doc" in result
assert "f1" in result
assert "2024-06-01T12:00:00Z" not in result
assert "http://link/f1" not in result
@pytest.mark.asyncio
async def test_search_detailed_true_with_size():
"""When the item has a size field, detailed=True includes it in output."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file("f2", "Big File", "application/pdf", size="102400"),
]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="big",
detailed=True,
)
assert "102400" in result
@pytest.mark.asyncio
async def test_search_detailed_true_requests_extra_api_fields():
"""detailed=True passes full fields string to the Drive API."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="anything",
detailed=True,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "modifiedTime" in call_kwargs["fields"]
assert "webViewLink" in call_kwargs["fields"]
assert "size" in call_kwargs["fields"]
@pytest.mark.asyncio
async def test_search_detailed_false_requests_compact_api_fields():
"""detailed=False passes compact fields string to the Drive API."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="anything",
detailed=False,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "modifiedTime" not in call_kwargs["fields"]
assert "webViewLink" not in call_kwargs["fields"]
assert "size" not in call_kwargs["fields"]
@pytest.mark.asyncio
async def test_search_default_detailed_matches_detailed_true():
"""Omitting detailed produces the same output as detailed=True."""
file = _make_file(
"f1",
"Doc",
"application/vnd.google-apps.document",
modified="2024-01-01T00:00:00Z",
link="http://l",
)
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": [file]}
result_default = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="doc",
)
mock_service.files().list().execute.return_value = {"files": [file]}
result_true = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="doc",
detailed=True,
)
assert result_default == result_true
# ---------------------------------------------------------------------------
# list_drive_items — detailed flag
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_detailed_true_output_includes_metadata(mock_resolve_folder):
"""detailed=True (default) includes modified time and link in output."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file(
"id1",
"Report",
"application/vnd.google-apps.document",
modified="2024-03-15T08:00:00Z",
link="http://link/id1",
)
]
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
detailed=True,
)
assert "Report" in result
assert "2024-03-15T08:00:00Z" in result
assert "http://link/id1" in result
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_detailed_false_output_excludes_metadata(mock_resolve_folder):
"""detailed=False omits modified time and link from output."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file(
"id1",
"Report",
"application/vnd.google-apps.document",
modified="2024-03-15T08:00:00Z",
link="http://link/id1",
)
]
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
detailed=False,
)
assert "Report" in result
assert "id1" in result
assert "2024-03-15T08:00:00Z" not in result
assert "http://link/id1" not in result
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_detailed_true_with_size(mock_resolve_folder):
"""When item has a size field, detailed=True includes it in output."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file("id2", "Big File", "application/pdf", size="204800"),
]
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
detailed=True,
)
assert "204800" in result
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_detailed_true_requests_extra_api_fields(mock_resolve_folder):
"""detailed=True passes full fields string to the Drive API."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
detailed=True,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "modifiedTime" in call_kwargs["fields"]
assert "webViewLink" in call_kwargs["fields"]
assert "size" in call_kwargs["fields"]
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_detailed_false_requests_compact_api_fields(mock_resolve_folder):
"""detailed=False passes compact fields string to the Drive API."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
detailed=False,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "modifiedTime" not in call_kwargs["fields"]
assert "webViewLink" not in call_kwargs["fields"]
assert "size" not in call_kwargs["fields"]
# ---------------------------------------------------------------------------
# Existing behavior coverage
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_free_text_returns_results():
"""Free-text query is wrapped in fullText contains and results are formatted."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file("f1", "My Doc", "application/vnd.google-apps.document"),
]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="my doc",
)
assert "Found 1 files" in result
assert "My Doc" in result
assert "f1" in result
@pytest.mark.asyncio
async def test_search_no_results():
"""No results returns a clear message."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="nothing here",
)
assert "No files found" in result
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_basic(mock_resolve_folder):
"""Basic listing without filters returns all items."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file("id1", "Folder A", "application/vnd.google-apps.folder"),
_make_file("id2", "Doc B", "application/vnd.google-apps.document"),
]
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
)
assert "Found 2 items" in result
assert "Folder A" in result
assert "Doc B" in result
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_no_results(mock_resolve_folder):
"""Empty folder returns a clear message."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
)
assert "No items found" in result
# ---------------------------------------------------------------------------
# file_type filtering
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_file_type_folder_adds_mime_filter():
"""file_type='folder' appends the folder MIME type to the query."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [
_make_file("fold1", "My Folder", "application/vnd.google-apps.folder")
]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="my",
file_type="folder",
)
assert "Found 1 files" in result
assert "My Folder" in result
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_document_alias():
"""Alias 'doc' resolves to the Google Docs MIME type."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="report",
file_type="doc",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/vnd.google-apps.document'" in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_plural_alias():
"""Plural aliases are resolved for friendlier natural-language usage."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="project",
file_type="folders",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_sheet_alias():
"""Alias 'sheet' resolves to the Google Sheets MIME type."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="budget",
file_type="sheet",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_raw_mime():
"""A raw MIME type string is passed through unchanged."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [_make_file("p1", "Report.pdf", "application/pdf")]
}
result = await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="report",
file_type="application/pdf",
)
assert "Report.pdf" in result
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/pdf'" in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_none_no_mime_filter():
"""When file_type is None no mimeType clause is added to the query."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="anything",
file_type=None,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType" not in call_kwargs["q"]
@pytest.mark.asyncio
async def test_search_file_type_structured_query_combined():
"""file_type filter is appended even when the query is already structured."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="name contains 'budget'",
file_type="spreadsheet",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
q = call_kwargs["q"]
assert "name contains 'budget'" in q
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in q
@pytest.mark.asyncio
async def test_search_file_type_unknown_raises_value_error():
"""An unrecognised friendly type name raises ValueError immediately."""
mock_service = Mock()
with pytest.raises(ValueError, match="Unknown file_type"):
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="something",
file_type="notatype",
)
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_file_type_folder_adds_mime_filter(mock_resolve_folder):
"""file_type='folder' appends the folder MIME clause to the query."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {
"files": [_make_file("sub1", "SubFolder", "application/vnd.google-apps.folder")]
}
result = await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
file_type="folder",
)
assert "Found 1 items" in result
assert "SubFolder" in result
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
q = call_kwargs["q"]
assert "'resolved_root' in parents" in q
assert "trashed=false" in q
assert "mimeType = 'application/vnd.google-apps.folder'" in q
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_file_type_spreadsheet(mock_resolve_folder):
"""file_type='spreadsheet' appends the Sheets MIME clause."""
mock_resolve_folder.return_value = "folder_xyz"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="folder_xyz",
file_type="spreadsheet",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"]
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_file_type_raw_mime(mock_resolve_folder):
"""A raw MIME type string is passed through unchanged."""
mock_resolve_folder.return_value = "folder_abc"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="folder_abc",
file_type="application/pdf",
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType = 'application/pdf'" in call_kwargs["q"]
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_file_type_none_no_mime_filter(mock_resolve_folder):
"""When file_type is None no mimeType clause is added."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
file_type=None,
)
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
assert "mimeType" not in call_kwargs["q"]
@pytest.mark.asyncio
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
async def test_list_items_file_type_unknown_raises(mock_resolve_folder):
"""An unrecognised friendly type name raises ValueError."""
mock_resolve_folder.return_value = "resolved_root"
mock_service = Mock()
with pytest.raises(ValueError, match="Unknown file_type"):
await _unwrap(list_drive_items)(
service=mock_service,
user_google_email="user@example.com",
folder_id="root",
file_type="unknowntype",
)
# ---------------------------------------------------------------------------
# OR-precedence grouping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_search_or_query_is_grouped_before_mime_filter():
"""An OR structured query is wrapped in parentheses so MIME filter precedence is correct."""
mock_service = Mock()
mock_service.files().list().execute.return_value = {"files": []}
await _unwrap(search_drive_files)(
service=mock_service,
user_google_email="user@example.com",
query="name contains 'a' or name contains 'b'",
file_type="document",
)
q = mock_service.files.return_value.list.call_args.kwargs["q"]
assert q.startswith("(")
assert "name contains 'a' or name contains 'b'" in q
assert ") and mimeType = 'application/vnd.google-apps.document'" in q
# ---------------------------------------------------------------------------
# MIME type validation
# ---------------------------------------------------------------------------
def test_resolve_file_type_mime_invalid_mime_raises():
"""A raw string with '/' but containing quotes raises ValueError."""
from gdrive.drive_helpers import resolve_file_type_mime
with pytest.raises(ValueError, match="Invalid MIME type"):
resolve_file_type_mime("application/pdf' or '1'='1")
def test_resolve_file_type_mime_strips_whitespace():
"""Leading/trailing whitespace is stripped from raw MIME strings."""
from gdrive.drive_helpers import resolve_file_type_mime
assert resolve_file_type_mime(" application/pdf ") == "application/pdf"
def test_resolve_file_type_mime_normalizes_case():
"""Raw MIME types are normalized to lowercase for Drive query consistency."""
from gdrive.drive_helpers import resolve_file_type_mime
assert resolve_file_type_mime("Application/PDF") == "application/pdf"
def test_resolve_file_type_mime_empty_raises():
"""Blank values are rejected with a clear validation error."""
from gdrive.drive_helpers import resolve_file_type_mime
with pytest.raises(ValueError, match="cannot be empty"):
resolve_file_type_mime(" ")

View File

@@ -0,0 +1,165 @@
"""
Unit tests for Drive SSRF protections and DNS pinning helpers.
"""
import os
import socket
import sys
import httpx
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gdrive import drive_tools
def test_resolve_and_validate_host_fails_closed_on_dns_error(monkeypatch):
"""DNS resolution failures must fail closed."""
def fake_getaddrinfo(hostname, port):
raise socket.gaierror("mocked resolution failure")
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
with pytest.raises(ValueError, match="Refusing request \\(fail-closed\\)"):
drive_tools._resolve_and_validate_host("example.com")
def test_resolve_and_validate_host_rejects_ipv6_private(monkeypatch):
"""IPv6 internal addresses must be rejected."""
def fake_getaddrinfo(hostname, port):
return [
(
socket.AF_INET6,
socket.SOCK_STREAM,
6,
"",
("fd00::1", 0, 0, 0),
)
]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
with pytest.raises(ValueError, match="private/internal networks"):
drive_tools._resolve_and_validate_host("ipv6-internal.example")
def test_resolve_and_validate_host_deduplicates_addresses(monkeypatch):
"""Duplicate DNS answers should be de-duplicated while preserving order."""
def fake_getaddrinfo(hostname, port):
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("93.184.216.34", 0),
),
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("93.184.216.34", 0),
),
(
socket.AF_INET6,
socket.SOCK_STREAM,
6,
"",
("2606:2800:220:1:248:1893:25c8:1946", 0, 0, 0),
),
]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
assert drive_tools._resolve_and_validate_host("example.com") == [
"93.184.216.34",
"2606:2800:220:1:248:1893:25c8:1946",
]
@pytest.mark.asyncio
async def test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_header(monkeypatch):
"""Requests should target a validated IP while preserving Host + SNI hostname."""
captured = {}
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
captured["client_kwargs"] = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def build_request(self, method, url, headers=None, extensions=None):
captured["method"] = method
captured["url"] = url
captured["headers"] = headers or {}
captured["extensions"] = extensions or {}
return {"url": url}
async def send(self, request):
return httpx.Response(200, request=httpx.Request("GET", request["url"]))
monkeypatch.setattr(
drive_tools, "_validate_url_not_internal", lambda url: ["93.184.216.34"]
)
monkeypatch.setattr(drive_tools.httpx, "AsyncClient", FakeAsyncClient)
response = await drive_tools._fetch_url_with_pinned_ip(
"https://example.com/path/to/file.txt?x=1"
)
assert response.status_code == 200
assert captured["method"] == "GET"
assert captured["url"] == "https://93.184.216.34/path/to/file.txt?x=1"
assert captured["headers"]["Host"] == "example.com"
assert captured["extensions"]["sni_hostname"] == "example.com"
assert captured["client_kwargs"]["trust_env"] is False
assert captured["client_kwargs"]["follow_redirects"] is False
@pytest.mark.asyncio
async def test_ssrf_safe_fetch_follows_relative_redirects(monkeypatch):
"""Relative redirects should be resolved and re-checked."""
calls = []
async def fake_fetch(url):
calls.append(url)
if len(calls) == 1:
return httpx.Response(
302,
headers={"location": "/next"},
request=httpx.Request("GET", url),
)
return httpx.Response(200, request=httpx.Request("GET", url), content=b"ok")
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
response = await drive_tools._ssrf_safe_fetch("https://example.com/start")
assert response.status_code == 200
assert calls == ["https://example.com/start", "https://example.com/next"]
@pytest.mark.asyncio
async def test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme(monkeypatch):
"""Redirects to non-http(s) schemes should be blocked."""
async def fake_fetch(url):
return httpx.Response(
302,
headers={"location": "file:///etc/passwd"},
request=httpx.Request("GET", url),
)
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
with pytest.raises(ValueError, match="Redirect to disallowed scheme"):
await drive_tools._ssrf_safe_fetch("https://example.com/start")

0
tests/gforms/__init__.py Normal file
View File

View File

@@ -0,0 +1,344 @@
"""
Unit tests for Google Forms MCP tools
Tests the batch_update_form tool with mocked API responses
"""
import pytest
from unittest.mock import Mock
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
# Import internal implementation functions (not decorated tool wrappers)
from gforms.forms_tools import _batch_update_form_impl, _serialize_form_item, get_form
@pytest.mark.asyncio
async def test_batch_update_form_multiple_requests():
"""Test batch update with multiple requests returns formatted results"""
mock_service = Mock()
mock_response = {
"replies": [
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
{"createItem": {"itemId": "item002", "questionId": ["q002"]}},
],
"writeControl": {"requiredRevisionId": "rev123"},
}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{
"createItem": {
"item": {
"title": "What is your name?",
"questionItem": {
"question": {"textQuestion": {"paragraph": False}}
},
},
"location": {"index": 0},
}
},
{
"createItem": {
"item": {
"title": "What is your email?",
"questionItem": {
"question": {"textQuestion": {"paragraph": False}}
},
},
"location": {"index": 1},
}
},
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="test_form_123",
requests=requests,
)
assert "Batch Update Completed" in result
assert "test_form_123" in result
assert "Requests Applied: 2" in result
assert "Replies Received: 2" in result
assert "item001" in result
assert "item002" in result
@pytest.mark.asyncio
async def test_batch_update_form_single_request():
"""Test batch update with a single request"""
mock_service = Mock()
mock_response = {
"replies": [
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
],
}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{
"createItem": {
"item": {
"title": "Favourite colour?",
"questionItem": {
"question": {
"choiceQuestion": {
"type": "RADIO",
"options": [
{"value": "Red"},
{"value": "Blue"},
],
}
}
},
},
"location": {"index": 0},
}
},
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="single_form_456",
requests=requests,
)
assert "single_form_456" in result
assert "Requests Applied: 1" in result
assert "Replies Received: 1" in result
@pytest.mark.asyncio
async def test_batch_update_form_empty_replies():
"""Test batch update when API returns no replies"""
mock_service = Mock()
mock_response = {
"replies": [],
}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{
"updateFormInfo": {
"info": {"description": "Updated description"},
"updateMask": "description",
}
},
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="info_form_789",
requests=requests,
)
assert "info_form_789" in result
assert "Requests Applied: 1" in result
assert "Replies Received: 0" in result
@pytest.mark.asyncio
async def test_batch_update_form_no_replies_key():
"""Test batch update when API response lacks replies key"""
mock_service = Mock()
mock_response = {}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{
"updateSettings": {
"settings": {"quizSettings": {"isQuiz": True}},
"updateMask": "quizSettings.isQuiz",
}
},
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="quiz_form_000",
requests=requests,
)
assert "quiz_form_000" in result
assert "Requests Applied: 1" in result
assert "Replies Received: 0" in result
@pytest.mark.asyncio
async def test_batch_update_form_url_in_response():
"""Test that the edit URL is included in the response"""
mock_service = Mock()
mock_response = {
"replies": [{}],
}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{"updateFormInfo": {"info": {"title": "New Title"}, "updateMask": "title"}}
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="url_form_abc",
requests=requests,
)
assert "https://docs.google.com/forms/d/url_form_abc/edit" in result
@pytest.mark.asyncio
async def test_batch_update_form_mixed_reply_types():
"""Test batch update with createItem replies containing different fields"""
mock_service = Mock()
mock_response = {
"replies": [
{"createItem": {"itemId": "item_a", "questionId": ["qa"]}},
{},
{"createItem": {"itemId": "item_c"}},
],
}
mock_service.forms().batchUpdate().execute.return_value = mock_response
requests = [
{"createItem": {"item": {"title": "Q1"}, "location": {"index": 0}}},
{
"updateFormInfo": {
"info": {"description": "Desc"},
"updateMask": "description",
}
},
{"createItem": {"item": {"title": "Q2"}, "location": {"index": 1}}},
]
result = await _batch_update_form_impl(
service=mock_service,
form_id="mixed_form_xyz",
requests=requests,
)
assert "Requests Applied: 3" in result
assert "Replies Received: 3" in result
assert "item_a" in result
assert "item_c" in result
def test_serialize_form_item_choice_question_includes_ids_and_options():
"""Choice question items should expose questionId/options/type metadata."""
item = {
"itemId": "item_123",
"title": "Favorite color?",
"questionItem": {
"question": {
"questionId": "q_123",
"required": True,
"choiceQuestion": {
"type": "RADIO",
"options": [{"value": "Red"}, {"value": "Blue"}],
},
}
},
}
serialized = _serialize_form_item(item, 1)
assert serialized["index"] == 1
assert serialized["itemId"] == "item_123"
assert serialized["type"] == "RADIO"
assert serialized["questionId"] == "q_123"
assert serialized["required"] is True
assert serialized["options"] == [{"value": "Red"}, {"value": "Blue"}]
def test_serialize_form_item_grid_includes_row_and_column_structure():
"""Grid question groups should expose row labels/IDs and column options."""
item = {
"itemId": "grid_item_1",
"title": "Weekly chores",
"questionGroupItem": {
"questions": [
{
"questionId": "row_q1",
"required": True,
"rowQuestion": {"title": "Laundry"},
},
{
"questionId": "row_q2",
"required": False,
"rowQuestion": {"title": "Dishes"},
},
],
"grid": {"columns": {"options": [{"value": "Never"}, {"value": "Often"}]}},
},
}
serialized = _serialize_form_item(item, 2)
assert serialized["index"] == 2
assert serialized["type"] == "GRID"
assert serialized["grid"]["columns"] == [{"value": "Never"}, {"value": "Often"}]
assert serialized["grid"]["rows"] == [
{"title": "Laundry", "questionId": "row_q1", "required": True},
{"title": "Dishes", "questionId": "row_q2", "required": False},
]
@pytest.mark.asyncio
async def test_get_form_returns_structured_item_metadata():
"""get_form should include question IDs, options, and grid structure."""
mock_service = Mock()
mock_service.forms().get().execute.return_value = {
"formId": "form_1",
"info": {"title": "Survey", "description": "Test survey"},
"items": [
{
"itemId": "item_1",
"title": "Favorite fruit?",
"questionItem": {
"question": {
"questionId": "q_1",
"required": True,
"choiceQuestion": {
"type": "RADIO",
"options": [{"value": "Apple"}, {"value": "Banana"}],
},
}
},
},
{
"itemId": "item_2",
"title": "Household chores",
"questionGroupItem": {
"questions": [
{
"questionId": "row_1",
"required": True,
"rowQuestion": {"title": "Laundry"},
}
],
"grid": {"columns": {"options": [{"value": "Never"}]}},
},
},
],
}
# Bypass decorators and call the core implementation directly.
result = await get_form.__wrapped__.__wrapped__(
mock_service, "user@example.com", "form_1"
)
assert "- Items (structured):" in result
assert '"questionId": "q_1"' in result
assert '"options": [' in result
assert '"Apple"' in result
assert '"type": "GRID"' in result
assert '"columns": [' in result
assert '"rows": [' in result

View File

@@ -0,0 +1,101 @@
import base64
import os
import sys
import pytest
def test_urlsafe_b64decode_already_handles_crlf():
"""Verify Python's urlsafe_b64decode ignores embedded CR/LF without manual stripping."""
original = b"Testdata"
b64 = base64.urlsafe_b64encode(original).decode()
assert base64.urlsafe_b64decode(b64 + "\n") == original
assert base64.urlsafe_b64decode(b64[:4] + "\r\n" + b64[4:]) == original
assert base64.urlsafe_b64decode(b64[:4] + "\r\r\n" + b64[4:]) == original
def test_os_open_without_o_binary_corrupts_on_windows(tmp_path):
"""On Windows, os.open without O_BINARY translates LF to CRLF in written bytes."""
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
tmp = str(tmp_path / "test_no_binary.bin")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, payload)
finally:
os.close(fd)
with open(tmp, "rb") as f:
written = f.read()
if sys.platform == "win32":
assert written != payload, "Expected corruption without O_BINARY on Windows"
assert len(written) > len(payload)
else:
assert written == payload
def test_os_open_with_o_binary_preserves_bytes(tmp_path):
"""os.open with O_BINARY writes binary data correctly on all platforms."""
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
tmp = str(tmp_path / "test_with_binary.bin")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
fd = os.open(tmp, flags, 0o600)
try:
os.write(fd, payload)
finally:
os.close(fd)
with open(tmp, "rb") as f:
written = f.read()
assert written == payload
@pytest.fixture
def isolated_storage(tmp_path, monkeypatch):
"""Create an AttachmentStorage that writes to a temp directory."""
import core.attachment_storage as storage_module
monkeypatch.setattr(storage_module, "STORAGE_DIR", tmp_path)
return storage_module.AttachmentStorage()
def test_save_attachment_uses_binary_mode(isolated_storage):
"""Verify that AttachmentStorage.save_attachment writes files in binary mode."""
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
b64_data = base64.urlsafe_b64encode(payload).decode()
result = isolated_storage.save_attachment(
b64_data, filename="test.png", mime_type="image/png"
)
with open(result.path, "rb") as f:
saved_bytes = f.read()
assert saved_bytes == payload, (
f"Binary corruption detected: wrote {len(payload)} bytes, "
f"read back {len(saved_bytes)} bytes"
)
@pytest.mark.parametrize(
"payload",
[
b"\x89PNG\r\n\x1a\n" + b"\xff" * 200, # PNG header
b"%PDF-1.7\n" + b"\x00" * 200, # PDF header
bytes(range(256)) * 4, # All byte values
],
)
def test_save_attachment_preserves_various_binary_formats(isolated_storage, payload):
"""Ensure binary integrity for payloads containing LF/CR bytes."""
b64_data = base64.urlsafe_b64encode(payload).decode()
result = isolated_storage.save_attachment(b64_data, filename="test.bin")
with open(result.path, "rb") as f:
saved_bytes = f.read()
assert saved_bytes == payload

View File

@@ -0,0 +1,288 @@
import base64
import os
import sys
from unittest.mock import Mock
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from core.utils import UserInputError
from gmail.gmail_tools import draft_gmail_message
def _unwrap(tool):
"""Unwrap FunctionTool + decorators to the original async function."""
fn = tool.fn if hasattr(tool, "fn") else tool
while hasattr(fn, "__wrapped__"):
fn = fn.__wrapped__
return fn
def _thread_response(*message_ids):
return {
"messages": [
{
"payload": {
"headers": [{"name": "Message-ID", "value": message_id}],
}
}
for message_id in message_ids
]
}
@pytest.mark.asyncio
async def test_draft_gmail_message_reports_actual_attachment_count(
tmp_path, monkeypatch
):
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
attachment_path = tmp_path / "sample.txt"
attachment_path.write_text("hello attachment", encoding="utf-8")
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Attachment test",
body="Please see attached.",
attachments=[{"path": str(attachment_path)}],
include_signature=False,
)
assert "Draft created with 1 attachment(s)! Draft ID: draft123" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_bytes = base64.urlsafe_b64decode(raw_message)
assert b"Content-Disposition: attachment;" in raw_bytes
assert b"sample.txt" in raw_bytes
@pytest.mark.asyncio
async def test_draft_gmail_message_raises_when_no_attachments_are_added(
tmp_path, monkeypatch
):
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
missing_path = tmp_path / "missing.txt"
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
with pytest.raises(UserInputError, match="No valid attachments were added"):
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Attachment test",
body="Please see attached.",
attachments=[{"path": str(missing_path)}],
include_signature=False,
)
@pytest.mark.asyncio
async def test_draft_gmail_message_appends_gmail_signature_html():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_sig"}
mock_service.users().settings().sendAs().list().execute.return_value = {
"sendAs": [
{
"sendAsEmail": "user@example.com",
"isPrimary": True,
"signature": "<div>Best,<br>Alice</div>",
}
]
}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Signature test",
body="<p>Hello</p>",
body_format="html",
include_signature=True,
)
assert "Draft created! Draft ID: draft_sig" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "<p>Hello</p>" in raw_text
assert "Best,<br>Alice" in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_autofills_reply_headers_from_thread():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
# Verify threads().get() was called with correct parameters
thread_get_kwargs = (
mock_service.users.return_value.threads.return_value.get.call_args.kwargs
)
assert thread_get_kwargs["userId"] == "me"
assert thread_get_kwargs["id"] == "thread123"
assert thread_get_kwargs["format"] == "metadata"
assert "Message-ID" in thread_get_kwargs["metadataHeaders"]
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg3@example.com>" in raw_text
assert (
"References: <msg1@example.com> <msg2@example.com> <msg3@example.com>"
in raw_text
)
assert create_kwargs["body"]["message"]["threadId"] == "thread123"
@pytest.mark.asyncio
async def test_draft_gmail_message_uses_explicit_in_reply_to_when_filling_references():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Replying to an earlier message.",
thread_id="thread123",
in_reply_to="<msg2@example.com>",
include_signature=False,
)
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg2@example.com>" in raw_text
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
assert "<msg3@example.com>" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_uses_explicit_references_when_filling_in_reply_to():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = _thread_response(
"<msg1@example.com>",
"<msg2@example.com>",
"<msg3@example.com>",
)
await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Replying to an earlier message.",
thread_id="thread123",
references="<msg1@example.com> <msg2@example.com>",
include_signature=False,
)
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To: <msg2@example.com>" in raw_text
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
assert "<msg3@example.com>" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_gracefully_degrades_when_thread_fetch_fails():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.side_effect = RuntimeError("boom")
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To:" not in raw_text
assert "References:" not in raw_text
@pytest.mark.asyncio
async def test_draft_gmail_message_gracefully_degrades_when_thread_has_no_messages():
mock_service = Mock()
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
mock_service.users().threads().get().execute.return_value = {"messages": []}
result = await _unwrap(draft_gmail_message)(
service=mock_service,
user_google_email="user@example.com",
to="recipient@example.com",
subject="Meeting tomorrow",
body="Thanks for the update.",
thread_id="thread123",
include_signature=False,
)
assert "Draft created! Draft ID: draft_reply" in result
create_kwargs = (
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
)
raw_message = create_kwargs["body"]["message"]["raw"]
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
assert "In-Reply-To:" not in raw_text
assert "References:" not in raw_text

View File

View File

@@ -0,0 +1,436 @@
"""
Unit tests for Google Sheets format_sheet_range tool enhancements
Tests the enhanced formatting parameters: wrap_strategy, horizontal_alignment,
vertical_alignment, bold, italic, and font_size.
"""
import pytest
from unittest.mock import Mock
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from gsheets.sheets_tools import _format_sheet_range_impl
def create_mock_service():
"""Create a properly configured mock Google Sheets service."""
mock_service = Mock()
mock_metadata = {"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]}
mock_service.spreadsheets().get().execute = Mock(return_value=mock_metadata)
mock_service.spreadsheets().batchUpdate().execute = Mock(return_value={})
return mock_service
@pytest.mark.asyncio
async def test_format_wrap_strategy_wrap():
"""Test wrap_strategy=WRAP applies text wrapping"""
mock_service = create_mock_service()
result = await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C10",
wrap_strategy="WRAP",
)
assert result["spreadsheet_id"] == "test_spreadsheet_123"
assert result["range_name"] == "A1:C10"
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "WRAP"
@pytest.mark.asyncio
async def test_format_wrap_strategy_clip():
"""Test wrap_strategy=CLIP clips text at cell boundary"""
mock_service = create_mock_service()
result = await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:B5",
wrap_strategy="CLIP",
)
assert result["spreadsheet_id"] == "test_spreadsheet_123"
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "CLIP"
@pytest.mark.asyncio
async def test_format_wrap_strategy_overflow():
"""Test wrap_strategy=OVERFLOW_CELL allows text overflow"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
wrap_strategy="OVERFLOW_CELL",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "OVERFLOW_CELL"
@pytest.mark.asyncio
async def test_format_horizontal_alignment_center():
"""Test horizontal_alignment=CENTER centers text"""
mock_service = create_mock_service()
result = await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:D10",
horizontal_alignment="CENTER",
)
assert result["spreadsheet_id"] == "test_spreadsheet_123"
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["horizontalAlignment"] == "CENTER"
@pytest.mark.asyncio
async def test_format_horizontal_alignment_left():
"""Test horizontal_alignment=LEFT aligns text left"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A10",
horizontal_alignment="LEFT",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["horizontalAlignment"] == "LEFT"
@pytest.mark.asyncio
async def test_format_horizontal_alignment_right():
"""Test horizontal_alignment=RIGHT aligns text right"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="B1:B10",
horizontal_alignment="RIGHT",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["horizontalAlignment"] == "RIGHT"
@pytest.mark.asyncio
async def test_format_vertical_alignment_top():
"""Test vertical_alignment=TOP aligns text to top"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C5",
vertical_alignment="TOP",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["verticalAlignment"] == "TOP"
@pytest.mark.asyncio
async def test_format_vertical_alignment_middle():
"""Test vertical_alignment=MIDDLE centers text vertically"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C5",
vertical_alignment="MIDDLE",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["verticalAlignment"] == "MIDDLE"
@pytest.mark.asyncio
async def test_format_vertical_alignment_bottom():
"""Test vertical_alignment=BOTTOM aligns text to bottom"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C5",
vertical_alignment="BOTTOM",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["verticalAlignment"] == "BOTTOM"
@pytest.mark.asyncio
async def test_format_bold_true():
"""Test bold=True applies bold text formatting"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
bold=True,
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["textFormat"]["bold"] is True
@pytest.mark.asyncio
async def test_format_italic_true():
"""Test italic=True applies italic text formatting"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
italic=True,
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["textFormat"]["italic"] is True
@pytest.mark.asyncio
async def test_format_font_size():
"""Test font_size applies specified font size"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:D5",
font_size=14,
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["textFormat"]["fontSize"] == 14
@pytest.mark.asyncio
async def test_format_combined_text_formatting():
"""Test combining bold, italic, and font_size"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
bold=True,
italic=True,
font_size=16,
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
text_format = cell_format["textFormat"]
assert text_format["bold"] is True
assert text_format["italic"] is True
assert text_format["fontSize"] == 16
@pytest.mark.asyncio
async def test_format_combined_alignment_and_wrap():
"""Test combining wrap_strategy with alignments"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C10",
wrap_strategy="WRAP",
horizontal_alignment="CENTER",
vertical_alignment="TOP",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "WRAP"
assert cell_format["horizontalAlignment"] == "CENTER"
assert cell_format["verticalAlignment"] == "TOP"
@pytest.mark.asyncio
async def test_format_all_new_params_with_existing():
"""Test combining new params with existing color params"""
mock_service = create_mock_service()
result = await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:D10",
background_color="#FFFFFF",
text_color="#000000",
wrap_strategy="WRAP",
horizontal_alignment="LEFT",
vertical_alignment="MIDDLE",
bold=True,
font_size=12,
)
assert result["spreadsheet_id"] == "test_spreadsheet_123"
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "WRAP"
assert cell_format["horizontalAlignment"] == "LEFT"
assert cell_format["verticalAlignment"] == "MIDDLE"
assert cell_format["textFormat"]["bold"] is True
assert cell_format["textFormat"]["fontSize"] == 12
assert "backgroundColor" in cell_format
@pytest.mark.asyncio
async def test_format_invalid_wrap_strategy():
"""Test invalid wrap_strategy raises error"""
mock_service = create_mock_service()
from core.utils import UserInputError
with pytest.raises(UserInputError) as exc_info:
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
wrap_strategy="INVALID",
)
error_msg = str(exc_info.value).lower()
assert "wrap_strategy" in error_msg or "wrap" in error_msg
@pytest.mark.asyncio
async def test_format_invalid_horizontal_alignment():
"""Test invalid horizontal_alignment raises error"""
mock_service = create_mock_service()
from core.utils import UserInputError
with pytest.raises(UserInputError) as exc_info:
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
horizontal_alignment="INVALID",
)
error_msg = str(exc_info.value).lower()
assert "horizontal" in error_msg or "left" in error_msg
@pytest.mark.asyncio
async def test_format_invalid_vertical_alignment():
"""Test invalid vertical_alignment raises error"""
mock_service = create_mock_service()
from core.utils import UserInputError
with pytest.raises(UserInputError) as exc_info:
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
vertical_alignment="INVALID",
)
error_msg = str(exc_info.value).lower()
assert "vertical" in error_msg or "top" in error_msg
@pytest.mark.asyncio
async def test_format_case_insensitive_wrap_strategy():
"""Test wrap_strategy accepts lowercase input"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
wrap_strategy="wrap",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["wrapStrategy"] == "WRAP"
@pytest.mark.asyncio
async def test_format_case_insensitive_alignment():
"""Test alignments accept lowercase input"""
mock_service = create_mock_service()
await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:A1",
horizontal_alignment="center",
vertical_alignment="middle",
)
call_args = mock_service.spreadsheets().batchUpdate.call_args
request_body = call_args[1]["body"]
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
assert cell_format["horizontalAlignment"] == "CENTER"
assert cell_format["verticalAlignment"] == "MIDDLE"
@pytest.mark.asyncio
async def test_format_confirmation_message_includes_new_params():
"""Test confirmation message mentions new formatting applied"""
mock_service = create_mock_service()
result = await _format_sheet_range_impl(
service=mock_service,
spreadsheet_id="test_spreadsheet_123",
range_name="A1:C10",
wrap_strategy="WRAP",
bold=True,
font_size=14,
)
assert result["spreadsheet_id"] == "test_spreadsheet_123"
assert result["range_name"] == "A1:C10"

View File

@@ -0,0 +1,60 @@
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import main
def test_resolve_permissions_mode_selection_without_tier():
services = ["gmail", "drive"]
resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection(
services, None
)
assert resolved_services == services
assert tier_tool_filter is None
def test_resolve_permissions_mode_selection_with_tier_filters_services(monkeypatch):
def fake_resolve_tools_from_tier(tier, services):
assert tier == "core"
assert services == ["gmail", "drive", "slides"]
return ["search_gmail_messages"], ["gmail"]
monkeypatch.setattr(main, "resolve_tools_from_tier", fake_resolve_tools_from_tier)
resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection(
["gmail", "drive", "slides"], "core"
)
assert resolved_services == ["gmail"]
assert tier_tool_filter == {"search_gmail_messages"}
def test_narrow_permissions_to_services_keeps_selected_order():
permissions = {"drive": "full", "gmail": "readonly", "calendar": "readonly"}
narrowed = main.narrow_permissions_to_services(permissions, ["gmail", "drive"])
assert narrowed == {"gmail": "readonly", "drive": "full"}
def test_narrow_permissions_to_services_drops_non_selected_services():
permissions = {"gmail": "send", "drive": "full"}
narrowed = main.narrow_permissions_to_services(permissions, ["gmail"])
assert narrowed == {"gmail": "send"}
def test_permissions_and_tools_flags_are_rejected(monkeypatch, capsys):
monkeypatch.setattr(main, "configure_safe_logging", lambda: None)
monkeypatch.setattr(
sys,
"argv",
["main.py", "--permissions", "gmail:readonly", "--tools", "gmail"],
)
with pytest.raises(SystemExit) as exc:
main.main()
assert exc.value.code == 1
captured = capsys.readouterr()
assert "--permissions and --tools cannot be combined" in captured.err

201
tests/test_permissions.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Unit tests for granular per-service permission parsing and scope resolution.
Covers parse_permissions_arg() validation (format, duplicates, unknown
service/level) and cumulative scope expansion in get_scopes_for_permission().
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from auth.permissions import (
get_scopes_for_permission,
is_action_denied,
parse_permissions_arg,
set_permissions,
SERVICE_PERMISSION_LEVELS,
)
from auth.scopes import (
GMAIL_READONLY_SCOPE,
GMAIL_LABELS_SCOPE,
GMAIL_MODIFY_SCOPE,
GMAIL_COMPOSE_SCOPE,
DRIVE_READONLY_SCOPE,
DRIVE_SCOPE,
TASKS_READONLY_SCOPE,
TASKS_SCOPE,
DRIVE_FILE_SCOPE,
)
class TestParsePermissionsArg:
"""Tests for parse_permissions_arg()."""
def test_single_valid_entry(self):
result = parse_permissions_arg(["gmail:readonly"])
assert result == {"gmail": "readonly"}
def test_multiple_valid_entries(self):
result = parse_permissions_arg(["gmail:organize", "drive:full"])
assert result == {"gmail": "organize", "drive": "full"}
def test_all_services_at_readonly(self):
entries = [f"{svc}:readonly" for svc in SERVICE_PERMISSION_LEVELS]
result = parse_permissions_arg(entries)
assert set(result.keys()) == set(SERVICE_PERMISSION_LEVELS.keys())
def test_missing_colon_raises(self):
with pytest.raises(ValueError, match="Invalid permission format"):
parse_permissions_arg(["gmail_readonly"])
def test_duplicate_service_raises(self):
with pytest.raises(ValueError, match="Duplicate service"):
parse_permissions_arg(["gmail:readonly", "gmail:full"])
def test_unknown_service_raises(self):
with pytest.raises(ValueError, match="Unknown service"):
parse_permissions_arg(["fakesvc:readonly"])
def test_unknown_level_raises(self):
with pytest.raises(ValueError, match="Unknown level"):
parse_permissions_arg(["gmail:superadmin"])
def test_empty_list_returns_empty(self):
assert parse_permissions_arg([]) == {}
def test_extra_colon_in_value(self):
"""A level containing a colon should fail as unknown level."""
with pytest.raises(ValueError, match="Unknown level"):
parse_permissions_arg(["gmail:read:only"])
def test_tasks_manage_is_valid_level(self):
"""tasks:manage should be accepted by parse_permissions_arg."""
result = parse_permissions_arg(["tasks:manage"])
assert result == {"tasks": "manage"}
class TestGetScopesForPermission:
"""Tests for get_scopes_for_permission() cumulative scope expansion."""
def test_gmail_readonly_returns_readonly_scope(self):
scopes = get_scopes_for_permission("gmail", "readonly")
assert GMAIL_READONLY_SCOPE in scopes
def test_gmail_organize_includes_readonly(self):
"""Organize level should cumulatively include readonly scopes."""
scopes = get_scopes_for_permission("gmail", "organize")
assert GMAIL_READONLY_SCOPE in scopes
assert GMAIL_LABELS_SCOPE in scopes
assert GMAIL_MODIFY_SCOPE in scopes
def test_gmail_drafts_includes_organize_and_readonly(self):
scopes = get_scopes_for_permission("gmail", "drafts")
assert GMAIL_READONLY_SCOPE in scopes
assert GMAIL_LABELS_SCOPE in scopes
assert GMAIL_COMPOSE_SCOPE in scopes
def test_drive_readonly_excludes_full(self):
scopes = get_scopes_for_permission("drive", "readonly")
assert DRIVE_READONLY_SCOPE in scopes
assert DRIVE_SCOPE not in scopes
assert DRIVE_FILE_SCOPE not in scopes
def test_drive_full_includes_readonly(self):
scopes = get_scopes_for_permission("drive", "full")
assert DRIVE_READONLY_SCOPE in scopes
assert DRIVE_SCOPE in scopes
def test_unknown_service_raises(self):
with pytest.raises(ValueError, match="Unknown service"):
get_scopes_for_permission("nonexistent", "readonly")
def test_unknown_level_raises(self):
with pytest.raises(ValueError, match="Unknown permission level"):
get_scopes_for_permission("gmail", "nonexistent")
def test_no_duplicate_scopes(self):
"""Cumulative expansion should deduplicate scopes."""
for service, levels in SERVICE_PERMISSION_LEVELS.items():
for level_name, _ in levels:
scopes = get_scopes_for_permission(service, level_name)
assert len(scopes) == len(set(scopes)), (
f"Duplicate scopes for {service}:{level_name}"
)
def test_tasks_manage_includes_write_scope(self):
"""Manage level should cumulatively include readonly and write scopes."""
scopes = get_scopes_for_permission("tasks", "manage")
assert TASKS_SCOPE in scopes
assert TASKS_READONLY_SCOPE in scopes
def test_tasks_full_includes_write_scope(self):
"""Full level should include write and readonly scopes from lower levels."""
scopes = get_scopes_for_permission("tasks", "full")
assert TASKS_SCOPE in scopes
assert TASKS_READONLY_SCOPE in scopes
@pytest.fixture(autouse=True)
def _reset_permissions_state():
"""Ensure each test starts and ends with no active permissions."""
set_permissions(None)
yield
set_permissions(None)
class TestIsActionDenied:
"""Tests for is_action_denied() and SERVICE_DENIED_ACTIONS."""
def test_no_permissions_mode_allows_all(self):
"""Without granular permissions, no action is denied."""
set_permissions(None)
assert is_action_denied("tasks", "delete") is False
def test_tasks_full_allows_delete(self):
"""Full level should not deny delete."""
set_permissions({"tasks": "full"})
assert is_action_denied("tasks", "delete") is False
def test_tasks_manage_denies_delete(self):
"""Manage level should deny delete."""
set_permissions({"tasks": "manage"})
assert is_action_denied("tasks", "delete") is True
def test_tasks_manage_allows_create(self):
"""Manage level should allow create."""
set_permissions({"tasks": "manage"})
assert is_action_denied("tasks", "create") is False
def test_tasks_manage_allows_update(self):
"""Manage level should allow update."""
set_permissions({"tasks": "manage"})
assert is_action_denied("tasks", "update") is False
def test_tasks_manage_allows_move(self):
"""Manage level should allow move."""
set_permissions({"tasks": "manage"})
assert is_action_denied("tasks", "move") is False
def test_tasks_manage_denies_clear_completed(self):
"""Manage level should deny clear_completed."""
set_permissions({"tasks": "manage"})
assert is_action_denied("tasks", "clear_completed") is True
def test_tasks_full_allows_clear_completed(self):
"""Full level should not deny clear_completed."""
set_permissions({"tasks": "full"})
assert is_action_denied("tasks", "clear_completed") is False
def test_service_not_in_permissions_allows_all(self):
"""A service not listed in permissions should allow all actions."""
set_permissions({"gmail": "readonly"})
assert is_action_denied("tasks", "delete") is False
def test_service_without_denied_actions_allows_all(self):
"""A service with no SERVICE_DENIED_ACTIONS entry should allow all actions."""
set_permissions({"gmail": "readonly"})
assert is_action_denied("gmail", "delete") is False

231
tests/test_scopes.py Normal file
View File

@@ -0,0 +1,231 @@
"""
Unit tests for cross-service scope generation.
Verifies that docs and sheets tools automatically include the Drive scopes
they need for operations like search_docs, list_docs_in_folder,
export_doc_to_pdf, and list_spreadsheets — without requiring --tools drive.
"""
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from auth.scopes import (
BASE_SCOPES,
CALENDAR_READONLY_SCOPE,
CALENDAR_SCOPE,
CONTACTS_READONLY_SCOPE,
CONTACTS_SCOPE,
DRIVE_FILE_SCOPE,
DRIVE_READONLY_SCOPE,
DRIVE_SCOPE,
GMAIL_COMPOSE_SCOPE,
GMAIL_LABELS_SCOPE,
GMAIL_MODIFY_SCOPE,
GMAIL_READONLY_SCOPE,
GMAIL_SEND_SCOPE,
GMAIL_SETTINGS_BASIC_SCOPE,
SHEETS_READONLY_SCOPE,
SHEETS_WRITE_SCOPE,
get_scopes_for_tools,
has_required_scopes,
set_read_only,
)
from auth.permissions import get_scopes_for_permission, set_permissions
import auth.permissions as permissions_module
class TestDocsScopes:
"""Tests for docs tool scope generation."""
def test_docs_includes_drive_readonly(self):
"""search_docs, get_doc_content, list_docs_in_folder need drive.readonly."""
scopes = get_scopes_for_tools(["docs"])
assert DRIVE_READONLY_SCOPE in scopes
def test_docs_includes_drive_file(self):
"""export_doc_to_pdf needs drive.file to create the PDF."""
scopes = get_scopes_for_tools(["docs"])
assert DRIVE_FILE_SCOPE in scopes
def test_docs_does_not_include_full_drive(self):
"""docs should NOT request full drive access."""
scopes = get_scopes_for_tools(["docs"])
assert DRIVE_SCOPE not in scopes
class TestSheetsScopes:
"""Tests for sheets tool scope generation."""
def test_sheets_includes_drive_readonly(self):
"""list_spreadsheets needs drive.readonly."""
scopes = get_scopes_for_tools(["sheets"])
assert DRIVE_READONLY_SCOPE in scopes
def test_sheets_does_not_include_full_drive(self):
"""sheets should NOT request full drive access."""
scopes = get_scopes_for_tools(["sheets"])
assert DRIVE_SCOPE not in scopes
class TestCombinedScopes:
"""Tests for combined tool scope generation."""
def test_docs_sheets_no_duplicate_drive_readonly(self):
"""Combined docs+sheets should deduplicate drive.readonly."""
scopes = get_scopes_for_tools(["docs", "sheets"])
assert scopes.count(DRIVE_READONLY_SCOPE) <= 1
def test_docs_sheets_returns_unique_scopes(self):
"""All returned scopes should be unique."""
scopes = get_scopes_for_tools(["docs", "sheets"])
assert len(scopes) == len(set(scopes))
class TestReadOnlyScopes:
"""Tests for read-only mode scope generation."""
def setup_method(self):
set_read_only(False)
def teardown_method(self):
set_read_only(False)
def test_docs_readonly_includes_drive_readonly(self):
"""Even in read-only mode, docs needs drive.readonly for search/list."""
set_read_only(True)
scopes = get_scopes_for_tools(["docs"])
assert DRIVE_READONLY_SCOPE in scopes
def test_docs_readonly_excludes_drive_file(self):
"""In read-only mode, docs should NOT request drive.file."""
set_read_only(True)
scopes = get_scopes_for_tools(["docs"])
assert DRIVE_FILE_SCOPE not in scopes
def test_sheets_readonly_includes_drive_readonly(self):
"""Even in read-only mode, sheets needs drive.readonly for list."""
set_read_only(True)
scopes = get_scopes_for_tools(["sheets"])
assert DRIVE_READONLY_SCOPE in scopes
class TestHasRequiredScopes:
"""Tests for hierarchy-aware scope checking."""
def test_exact_match(self):
"""Exact scope match should pass."""
assert has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_READONLY_SCOPE])
def test_missing_scope_fails(self):
"""Missing scope with no covering broader scope should fail."""
assert not has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_SEND_SCOPE])
def test_empty_available_fails(self):
"""Empty available scopes should fail when scopes are required."""
assert not has_required_scopes([], [GMAIL_READONLY_SCOPE])
def test_empty_required_passes(self):
"""No required scopes should always pass."""
assert has_required_scopes([], [])
assert has_required_scopes([GMAIL_READONLY_SCOPE], [])
def test_none_available_fails(self):
"""None available scopes should fail when scopes are required."""
assert not has_required_scopes(None, [GMAIL_READONLY_SCOPE])
def test_none_available_empty_required_passes(self):
"""None available with no required scopes should pass."""
assert has_required_scopes(None, [])
# Gmail hierarchy: gmail.modify covers readonly, send, compose, labels
def test_gmail_modify_covers_readonly(self):
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_READONLY_SCOPE])
def test_gmail_modify_covers_send(self):
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_SEND_SCOPE])
def test_gmail_modify_covers_compose(self):
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_COMPOSE_SCOPE])
def test_gmail_modify_covers_labels(self):
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_LABELS_SCOPE])
def test_gmail_modify_does_not_cover_settings(self):
"""gmail.modify does NOT cover gmail.settings.basic."""
assert not has_required_scopes(
[GMAIL_MODIFY_SCOPE], [GMAIL_SETTINGS_BASIC_SCOPE]
)
def test_gmail_modify_covers_multiple_children(self):
"""gmail.modify should satisfy multiple child scopes at once."""
assert has_required_scopes(
[GMAIL_MODIFY_SCOPE],
[GMAIL_READONLY_SCOPE, GMAIL_SEND_SCOPE, GMAIL_LABELS_SCOPE],
)
# Drive hierarchy: drive covers drive.readonly and drive.file
def test_drive_covers_readonly(self):
assert has_required_scopes([DRIVE_SCOPE], [DRIVE_READONLY_SCOPE])
def test_drive_covers_file(self):
assert has_required_scopes([DRIVE_SCOPE], [DRIVE_FILE_SCOPE])
def test_drive_readonly_does_not_cover_full(self):
"""Narrower scope should not satisfy broader scope."""
assert not has_required_scopes([DRIVE_READONLY_SCOPE], [DRIVE_SCOPE])
# Other hierarchies
def test_calendar_covers_readonly(self):
assert has_required_scopes([CALENDAR_SCOPE], [CALENDAR_READONLY_SCOPE])
def test_sheets_write_covers_readonly(self):
assert has_required_scopes([SHEETS_WRITE_SCOPE], [SHEETS_READONLY_SCOPE])
def test_contacts_covers_readonly(self):
assert has_required_scopes([CONTACTS_SCOPE], [CONTACTS_READONLY_SCOPE])
# Mixed: some exact, some via hierarchy
def test_mixed_exact_and_hierarchy(self):
"""Combination of exact matches and hierarchy-implied scopes."""
available = [GMAIL_MODIFY_SCOPE, DRIVE_READONLY_SCOPE]
required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE]
assert has_required_scopes(available, required)
def test_mixed_partial_failure(self):
"""Should fail if hierarchy covers some but not all required scopes."""
available = [GMAIL_MODIFY_SCOPE]
required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE]
assert not has_required_scopes(available, required)
class TestGranularPermissionsScopes:
"""Tests for granular permissions scope generation path."""
def setup_method(self):
set_read_only(False)
permissions_module._PERMISSIONS = None
def teardown_method(self):
set_read_only(False)
permissions_module._PERMISSIONS = None
def test_permissions_mode_returns_base_plus_permission_scopes(self):
set_permissions({"gmail": "send", "drive": "readonly"})
scopes = get_scopes_for_tools(["calendar"]) # ignored in permissions mode
expected = set(BASE_SCOPES)
expected.update(get_scopes_for_permission("gmail", "send"))
expected.update(get_scopes_for_permission("drive", "readonly"))
assert set(scopes) == expected
def test_permissions_mode_overrides_read_only_and_full_maps(self):
set_read_only(True)
without_permissions = get_scopes_for_tools(["drive"])
assert DRIVE_READONLY_SCOPE in without_permissions
set_permissions({"gmail": "readonly"})
with_permissions = get_scopes_for_tools(["drive"])
assert GMAIL_READONLY_SCOPE in with_permissions
assert DRIVE_READONLY_SCOPE not in with_permissions