Files
mcp-nextcloud/tests/server/login_flow/test_login_flow_integration.py
T
Chris CoutinhoandClaude Opus 4.7 0c14501a2b test: align CI assertions with documented contracts
Two unrelated CI failures on this branch, one fix each:

- tests/integration/test_deck_vector_search.py: pass str(card.id) to
  get_chunk_with_context. The function's contract is doc_id: str
  (keyword-indexed in Qdrant), and real callers (viz_routes.py URL
  path, server/semantic.py via str(result.id)) all stringify. The
  test was the only int caller, hitting the .isdigit() guard added
  earlier on this branch.

- tests/server/login_flow/test_login_flow_integration.py:
  test_check_status_provisioned now accepts scopes=None as valid.
  Per ProvisionStatusResponse in models/auth.py, None is the
  documented sentinel for "all scopes granted" — and the web
  provisioning path (provision_routes.py, used by Astrolabe's
  "Enable Semantic Search" flow exercised by the new regression test
  added on this branch) stores exactly that. The previous
  is-not-None assertion hid behind test order until that flow ran.

- Replace anyio.sleep(0) with anyio.lowlevel.checkpoint()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:28:15 +02:00

659 lines
26 KiB
Python

"""Integration tests for Login Flow v2 (ADR-022).
Tests the complete Login Flow v2 provisioning and verifies all MCP tools
work through the stored app password. This validates the end-to-end flow:
OAuth token (MCP session) → Login Flow v2 (browser) → App password → Nextcloud API
Test categories:
1. Auth tools: provision, check status, scope management
2. Notes: CRUD operations
3. Calendar: events and todos
4. Contacts: address book and contact operations
5. Files (WebDAV): directory listing, file operations
6. Deck: board management
7. Cookbook: recipe operations
8. Tables: table operations
"""
import json
import logging
import uuid
import pytest
from mcp import ClientSession
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.login_flow, pytest.mark.integration]
# ---------------------------------------------------------------------------
# Auth tools
# ---------------------------------------------------------------------------
class TestLoginFlowAuthTools:
"""Test Login Flow v2 auth tools."""
async def test_check_status_provisioned(
self, nc_mcp_login_flow_client: ClientSession
):
"""After fixture setup, status should be 'provisioned'."""
result = await nc_mcp_login_flow_client.call_tool("nc_auth_check_status", {})
data = json.loads(result.content[0].text)
assert data["status"] == "provisioned"
assert data["username"] is not None
# ``scopes`` may legitimately be ``None`` — per ProvisionStatusResponse
# in models/auth.py, ``None`` is the documented sentinel for "all
# scopes granted" and is what the web provisioning path
# (``provision_routes.py``, used by Astrolabe's "Enable Semantic
# Search" flow) stores. So accept either a non-empty list or None;
# the field's *presence* in the payload is what we care about here.
assert data["scopes"] is None or len(data["scopes"]) > 0
logger.info(f"Provisioned as: {data['username']}, scopes: {data['scopes']}")
async def test_provision_access_already_provisioned(
self, nc_mcp_login_flow_client: ClientSession
):
"""Calling provision when already provisioned returns 'already_provisioned'."""
result = await nc_mcp_login_flow_client.call_tool(
"nc_auth_provision_access", {}
)
data = json.loads(result.content[0].text)
assert data["status"] == "already_provisioned"
assert "already provisioned" in data["message"].lower()
async def test_list_tools_includes_auth_tools(
self, nc_mcp_login_flow_client: ClientSession
):
"""Login Flow server should expose auth tools."""
tools = await nc_mcp_login_flow_client.list_tools()
tool_names = [t.name for t in tools.tools]
assert "nc_auth_provision_access" in tool_names
assert "nc_auth_check_status" in tool_names
assert "nc_auth_update_scopes" in tool_names
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
class TestLoginFlowNotes:
"""Test Notes CRUD via Login Flow v2 app password."""
async def test_notes_crud(self, nc_mcp_login_flow_client: ClientSession):
"""Full Notes CRUD: create → read → update → search → delete."""
suffix = uuid.uuid4().hex[:8]
title = f"LoginFlow Test {suffix}"
content = f"Content for {suffix}"
category = "LoginFlowTest"
# Create
create_result = await nc_mcp_login_flow_client.call_tool(
"nc_notes_create_note",
{"title": title, "content": content, "category": category},
)
assert create_result.isError is False, (
f"Create failed: {create_result.content[0].text}"
)
note = json.loads(create_result.content[0].text)
note_id = note["id"]
etag = note["etag"]
logger.info(f"Created note {note_id}")
try:
# Read
read_result = await nc_mcp_login_flow_client.call_tool(
"nc_notes_get_note", {"note_id": note_id}
)
assert read_result.isError is False
read_data = json.loads(read_result.content[0].text)
assert read_data["title"] == title
assert read_data["content"] == content
# Update (title, content, category are all required params)
updated_content = f"Updated content for {suffix}"
update_result = await nc_mcp_login_flow_client.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"title": title,
"content": updated_content,
"category": category,
"etag": etag,
},
)
assert update_result.isError is False, (
f"Update failed: {update_result.content[0].text}"
)
updated = json.loads(update_result.content[0].text)
# UpdateNoteResponse returns id, title, category, etag (no content)
assert updated["title"] == title
assert "etag" in updated
# Append
append_result = await nc_mcp_login_flow_client.call_tool(
"nc_notes_append_content",
{"note_id": note_id, "content": "\n\nAppended text"},
)
assert append_result.isError is False
# Search
search_result = await nc_mcp_login_flow_client.call_tool(
"nc_notes_search_notes", {"query": suffix}
)
assert search_result.isError is False
search_data = json.loads(search_result.content[0].text)
assert search_data["total_found"] >= 1
finally:
# Delete
await nc_mcp_login_flow_client.call_tool(
"nc_notes_delete_note", {"note_id": note_id}
)
logger.info(f"Deleted note {note_id}")
# ---------------------------------------------------------------------------
# Calendar Events
# ---------------------------------------------------------------------------
class TestLoginFlowCalendarEvents:
"""Test Calendar event operations via Login Flow v2."""
async def test_calendar_events_workflow(
self, nc_mcp_login_flow_client: ClientSession
):
"""List calendars → create event → get event → delete event."""
# List calendars
cal_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_list_calendars", {}
)
assert cal_result.isError is False
cal_data = json.loads(cal_result.content[0].text)
calendars = cal_data.get("calendars", [])
assert len(calendars) > 0
calendar_name = calendars[0].get("name", "personal")
logger.info(f"Using calendar: {calendar_name}")
suffix = uuid.uuid4().hex[:8]
event_title = f"LoginFlow Event {suffix}"
# Create event (uses start_datetime/end_datetime)
create_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_create_event",
{
"calendar_name": calendar_name,
"title": event_title,
"start_datetime": "2026-03-01T10:00:00",
"end_datetime": "2026-03-01T11:00:00",
"description": f"Test event for login flow {suffix}",
},
)
assert create_result.isError is False, (
f"Create event failed: {create_result.content[0].text}"
)
event_data = json.loads(create_result.content[0].text)
event_uid = event_data.get("uid") or event_data.get("event_uid")
logger.info(f"Created event: {event_uid}")
try:
# Get event
get_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_get_event",
{"calendar_name": calendar_name, "event_uid": event_uid},
)
assert get_result.isError is False
finally:
# Delete event
await nc_mcp_login_flow_client.call_tool(
"nc_calendar_delete_event",
{"calendar_name": calendar_name, "event_uid": event_uid},
)
logger.info(f"Deleted event {event_uid}")
# ---------------------------------------------------------------------------
# Calendar Todos
# ---------------------------------------------------------------------------
class TestLoginFlowCalendarTodos:
"""Test Calendar todo (VTODO) operations via Login Flow v2."""
async def test_todo_workflow(self, nc_mcp_login_flow_client: ClientSession):
"""Create todo → list todos → update todo → delete todo."""
cal_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_list_calendars", {}
)
cal_data = json.loads(cal_result.content[0].text)
calendars = cal_data.get("calendars", [])
calendar_name = calendars[0].get("name", "personal")
suffix = uuid.uuid4().hex[:8]
todo_title = f"LoginFlow Todo {suffix}"
# Create todo (uses 'summary', not 'title')
create_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": todo_title,
"description": f"Test todo {suffix}",
},
)
if create_result.isError:
error_text = create_result.content[0].text
if "AuthorizationError" in error_text:
pytest.skip(
f"Calendar '{calendar_name}' does not support VTODO: {error_text}"
)
raise AssertionError(f"Create todo failed: {error_text}")
todo_data = json.loads(create_result.content[0].text)
todo_uid = todo_data.get("uid") or todo_data.get("todo_uid")
logger.info(f"Created todo: {todo_uid}")
try:
# List todos
list_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name},
)
assert list_result.isError is False
# Update todo
update_result = await nc_mcp_login_flow_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"percent_complete": 50,
},
)
assert update_result.isError is False
finally:
await nc_mcp_login_flow_client.call_tool(
"nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid},
)
logger.info(f"Deleted todo {todo_uid}")
# ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
class TestLoginFlowContacts:
"""Test Contacts (CardDAV) operations via Login Flow v2."""
async def test_contacts_workflow(self, nc_mcp_login_flow_client: ClientSession):
"""Create addressbook → create contact → list contacts → cleanup."""
suffix = uuid.uuid4().hex[:8]
ab_name = f"lf-test-{suffix}"
contact_uid = f"login-flow-test-{suffix}"
contact_fn = f"LoginFlow Contact {suffix}"
# List address books (basic smoke test)
ab_result = await nc_mcp_login_flow_client.call_tool(
"nc_contacts_list_addressbooks", {}
)
assert ab_result.isError is False
# Create a temporary address book for isolation
create_ab_result = await nc_mcp_login_flow_client.call_tool(
"nc_contacts_create_addressbook",
{"name": ab_name, "display_name": f"Login Flow Test {suffix}"},
)
assert create_ab_result.isError is False, (
f"Create addressbook failed: {create_ab_result.content[0].text}"
)
logger.info(f"Created address book: {ab_name}")
try:
# Create contact (requires addressbook, uid, contact_data dict)
create_result = await nc_mcp_login_flow_client.call_tool(
"nc_contacts_create_contact",
{
"addressbook": ab_name,
"uid": contact_uid,
"contact_data": {
"fn": contact_fn,
"email": f"test-{suffix}@example.com",
},
},
)
assert create_result.isError is False, (
f"Create contact failed: {create_result.content[0].text}"
)
logger.info(f"Created contact: {contact_uid}")
# List contacts in our clean addressbook
# Note: may fail due to server-side Pydantic bug where ContactField.value
# is a dict (structured email) but model expects string
list_result = await nc_mcp_login_flow_client.call_tool(
"nc_contacts_list_contacts",
{"addressbook": ab_name},
)
if list_result.isError:
error_text = list_result.content[0].text
if "ContactField" in error_text:
logger.warning(
f"Known server bug: ContactField validation: {error_text}"
)
else:
raise AssertionError(f"List contacts failed: {error_text}")
else:
list_data = json.loads(list_result.content[0].text)
contacts = list_data.get("contacts", [])
contact_uids = [c.get("uid", "") for c in contacts]
assert contact_uid in contact_uids, (
f"Created contact {contact_uid} not found in list"
)
# Delete contact
await nc_mcp_login_flow_client.call_tool(
"nc_contacts_delete_contact",
{"addressbook": ab_name, "uid": contact_uid},
)
logger.info(f"Deleted contact {contact_uid}")
finally:
# Always clean up the temporary address book
await nc_mcp_login_flow_client.call_tool(
"nc_contacts_delete_addressbook",
{"name": ab_name},
)
logger.info(f"Deleted address book {ab_name}")
# ---------------------------------------------------------------------------
# Files (WebDAV)
# ---------------------------------------------------------------------------
class TestLoginFlowFiles:
"""Test WebDAV file operations via Login Flow v2."""
async def test_file_operations(self, nc_mcp_login_flow_client: ClientSession):
"""Create dir → write file → read file → list dir → delete."""
suffix = uuid.uuid4().hex[:8]
dir_path = f"/LoginFlowTest_{suffix}"
file_path = f"{dir_path}/test_file.txt"
file_content = f"Hello from Login Flow v2 test {suffix}"
# Create directory
mkdir_result = await nc_mcp_login_flow_client.call_tool(
"nc_webdav_create_directory", {"path": dir_path}
)
assert mkdir_result.isError is False, (
f"Create dir failed: {mkdir_result.content[0].text}"
)
logger.info(f"Created directory: {dir_path}")
try:
# Write file
write_result = await nc_mcp_login_flow_client.call_tool(
"nc_webdav_write_file",
{"path": file_path, "content": file_content},
)
assert write_result.isError is False
# Read file
read_result = await nc_mcp_login_flow_client.call_tool(
"nc_webdav_read_file", {"path": file_path}
)
assert read_result.isError is False
read_data = json.loads(read_result.content[0].text)
assert file_content in read_data.get("content", "")
# List directory (response uses 'files' field, each with 'name')
list_result = await nc_mcp_login_flow_client.call_tool(
"nc_webdav_list_directory", {"path": dir_path}
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
files = list_data.get("files", [])
file_names = [f.get("name", "") for f in files]
assert "test_file.txt" in file_names
# Find files by name (uses 'pattern' and 'scope')
search_result = await nc_mcp_login_flow_client.call_tool(
"nc_webdav_find_by_name",
{"pattern": "test_file.txt", "scope": dir_path},
)
assert search_result.isError is False
finally:
# Clean up: delete file then directory
await nc_mcp_login_flow_client.call_tool(
"nc_webdav_delete_resource", {"path": file_path}
)
await nc_mcp_login_flow_client.call_tool(
"nc_webdav_delete_resource", {"path": dir_path}
)
logger.info(f"Cleaned up {dir_path}")
# ---------------------------------------------------------------------------
# Deck
# ---------------------------------------------------------------------------
class TestLoginFlowDeck:
"""Test Deck (Kanban) operations via Login Flow v2."""
async def test_deck_board_workflow(self, nc_mcp_login_flow_client: ClientSession):
"""Create board → list boards → get board details."""
import os
import httpx
suffix = uuid.uuid4().hex[:8]
board_title = f"LoginFlow Board {suffix}"
board_id = None
try:
# Create board (requires title and color)
create_result = await nc_mcp_login_flow_client.call_tool(
"deck_create_board", {"title": board_title, "color": "0076D1"}
)
assert create_result.isError is False, (
f"Create board failed: {create_result.content[0].text}"
)
board_data = json.loads(create_result.content[0].text)
board_id = board_data.get("id") or board_data.get("board_id")
logger.info(f"Created board: {board_id}")
# List boards (tool name is deck_get_boards)
list_result = await nc_mcp_login_flow_client.call_tool(
"deck_get_boards", {}
)
assert list_result.isError is False
boards_data = json.loads(list_result.content[0].text)
boards = boards_data.get("boards", [])
board_ids = [b.get("id") for b in boards]
assert board_id in board_ids
# Get board details
detail_result = await nc_mcp_login_flow_client.call_tool(
"deck_get_board", {"board_id": board_id}
)
assert detail_result.isError is False
finally:
# Clean up board via Deck REST API (no MCP delete_board tool exists)
if board_id is not None:
nc_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
nc_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
nc_pass = os.getenv("NEXTCLOUD_PASSWORD", "admin")
try:
async with httpx.AsyncClient(
base_url=nc_host,
auth=httpx.BasicAuth(nc_user, nc_pass),
headers={"OCS-APIREQUEST": "true"},
) as client:
resp = await client.delete(
f"/apps/deck/api/v1.0/boards/{board_id}"
)
logger.info(f"Board cleanup: {board_id}{resp.status_code}")
except Exception as e:
logger.warning(f"Board cleanup failed: {e}")
# ---------------------------------------------------------------------------
# Tables
# ---------------------------------------------------------------------------
class TestLoginFlowTables:
"""Test Tables operations via Login Flow v2."""
@pytest.mark.xfail(
reason="Server-side Pydantic bug: Table.owner_display_name required but missing from API",
strict=False,
)
async def test_tables_list(self, nc_mcp_login_flow_client: ClientSession):
"""List tables (may be empty but should not error)."""
result = await nc_mcp_login_flow_client.call_tool("nc_tables_list_tables", {})
assert result.isError is False, f"List tables failed: {result.content[0].text}"
data = json.loads(result.content[0].text)
logger.info(f"Tables: {data}")
# ---------------------------------------------------------------------------
# Cookbook
# ---------------------------------------------------------------------------
class TestLoginFlowCookbook:
"""Test Cookbook operations via Login Flow v2."""
async def test_cookbook_list_and_categories(
self, nc_mcp_login_flow_client: ClientSession
):
"""List recipes and categories (may be empty but should not error)."""
# List recipes
list_result = await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_list_recipes", {}
)
assert list_result.isError is False
# List categories
cat_result = await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_list_categories", {}
)
assert cat_result.isError is False
async def test_cookbook_create_and_delete(
self, nc_mcp_login_flow_client: ClientSession
):
"""Create recipe → get recipe → delete recipe."""
suffix = uuid.uuid4().hex[:8]
create_result = await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_create_recipe",
{
"name": f"LoginFlow Recipe {suffix}",
"description": f"Test recipe {suffix}",
"ingredients": ["flour", "sugar", "butter"],
"instructions": ["Mix ingredients", "Bake at 350F"],
"keywords": "test,login-flow", # keywords is a string, not list
},
)
assert create_result.isError is False, (
f"Create recipe failed: {create_result.content[0].text}"
)
recipe_data = json.loads(create_result.content[0].text)
recipe_id = recipe_data.get("id") or recipe_data.get("recipe_id")
logger.info(f"Created recipe: {recipe_id}")
try:
# Get recipe (may fail due to server-side Pydantic bug with recipeYield=None)
get_result = await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_get_recipe", {"recipe_id": recipe_id}
)
if get_result.isError:
error_text = get_result.content[0].text
if "recipeYield" in error_text:
logger.warning(
f"Known server bug: Recipe.recipeYield validation: {error_text}"
)
else:
raise AssertionError(f"Get recipe failed: {error_text}")
finally:
if recipe_id:
await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_delete_recipe", {"recipe_id": recipe_id}
)
logger.info(f"Deleted recipe {recipe_id}")
# ---------------------------------------------------------------------------
# Connectivity & Tool Listing
# ---------------------------------------------------------------------------
class TestLoginFlowConnectivity:
"""Basic connectivity and tool listing tests."""
async def test_list_tools(self, nc_mcp_login_flow_client: ClientSession):
"""Verify key tools are available."""
tools = await nc_mcp_login_flow_client.list_tools()
tool_names = [t.name for t in tools.tools]
# Auth tools (Login Flow v2 specific)
assert "nc_auth_provision_access" in tool_names
assert "nc_auth_check_status" in tool_names
assert "nc_auth_update_scopes" in tool_names
# Standard Nextcloud tools (verified against server/test_mcp.py)
expected = [
"nc_notes_create_note",
"nc_notes_search_notes",
"nc_notes_get_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_notes_append_content",
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
"nc_calendar_get_event",
"nc_calendar_delete_event",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_contacts_list_addressbooks",
"nc_contacts_create_contact",
"nc_contacts_list_contacts",
"nc_contacts_delete_contact",
"nc_webdav_list_directory",
"nc_webdav_read_file",
"nc_webdav_write_file",
"nc_webdav_create_directory",
"nc_webdav_delete_resource",
"nc_webdav_find_by_name",
"deck_create_board",
"deck_get_boards",
"deck_get_board",
"nc_tables_list_tables",
"nc_cookbook_list_recipes",
"nc_cookbook_create_recipe",
"nc_cookbook_get_recipe",
"nc_cookbook_delete_recipe",
"nc_cookbook_list_categories",
]
for tool in expected:
assert tool in tool_names, f"Expected tool '{tool}' not found"
async def test_list_resources(self, nc_mcp_login_flow_client: ClientSession):
"""Verify resource templates are available."""
templates = await nc_mcp_login_flow_client.list_resource_templates()
logger.info(f"Resource templates: {len(templates.resourceTemplates)}")