Files
mcp-nextcloud/tests/server/login_flow/test_login_flow_integration.py
T
Chris CoutinhoandClaude Opus 4.6 db1e0606ad fix: address PR #589 review feedback (round 2)
Consolidate three independent RefreshTokenStorage lazy singletons into a
single lock-protected get_shared_storage() function, eliminating race
conditions on concurrent first-access. Remove blanket try/except in
_get_stored_scopes so storage errors propagate as proper MCP errors
instead of silently triggering "please provision" messages. Handle
declined/cancelled elicitation results in Login Flow tools by cleaning up
sessions and returning clear status. Add update_app_password_scopes() to
avoid unnecessary decrypt/re-encrypt when only scopes change. Add
unprovisioned-user early exit and no-op detection to nc_auth_update_scopes.
Remove four dead config fields and misleading NEXTCLOUD_PASSWORD deprecation
warning. Add periodic login flow session cleanup task. Generate separate
Fernet keys per service. Add board cleanup in deck integration test. Gate
CI unit tests on linting and skip Astrolabe build for single-user profile.
Fix test markers from oauth to multi_user_basic for astrolabe integration
tests. Update login_flow.py docstrings to document outbound HTTP calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:35:31 +01:00

653 lines
25 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
assert data["scopes"] is not None
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)}")