Files
mcp-nextcloud/tests/unit/test_webdav_tools_exclusion.py
T
Chris CoutinhoandClaude Opus 4.7 56f01b3499 fix(webdav): address PR #764 review round 4
- Guard against malformed PROPFIND responses where tag["id"] is None
  before calling get_files_by_tag (prevents
  <oc:systemtag>None</oc:systemtag> dispatch).
- Add OCS-APIRequest: true header to get_tag_by_name and
  get_files_by_tag to match every other PROPFIND/REPORT in the file —
  fixes a latent reverse-proxy compatibility hazard.
- Add test_copy_resource_blocks_excluded_source to mirror the
  existing move-source coverage; closes the asymmetric test gap.
- Add test_skips_tag_with_missing_id covering the new fail-open
  branch in _resolve_one_tag.
- Reword _resolve_one_tag docstring: "distinct slot" was misleading
  (tasks append rather than pre-allocate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:24:11 +02:00

401 lines
13 KiB
Python

"""Server-layer regression tests for tag-based file exclusion (issue #710).
These tests register the WebDAV tools on a fresh ``FastMCP`` instance and
invoke each tool's underlying function directly via the tool registry.
Their purpose is **not** to re-test the path-matching logic (covered in
``test_tag_exclusion.py``) but to catch wiring regressions: that each
tool actually consults ``get_excluded_file_paths`` / ``is_path_excluded``
at the right point and raises / filters as expected.
The decorators on each tool (``@require_scopes``, ``@instrument_tool``)
are transparent under our mocked ``Context`` (no ``access_token`` set →
BasicAuth pass-through path).
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from nextcloud_mcp_server.server.webdav import configure_webdav_tools
pytestmark = pytest.mark.unit
@pytest.fixture
def webdav_tools() -> dict:
"""Register the WebDAV tools on a fresh FastMCP and return them by name."""
mcp = FastMCP(name="test-webdav-tools")
configure_webdav_tools(mcp)
return {t.name: t for t in mcp._tool_manager.list_tools()}
def _mock_ctx(client) -> SimpleNamespace:
"""Build a minimal Context-shaped object for the tool decorators.
Setting ``request_context.access_token = None`` causes ``require_scopes``
to take the BasicAuth pass-through branch (see scope_authorization.py).
"""
ctx = SimpleNamespace()
ctx.request_context = SimpleNamespace(access_token=None)
ctx._client = client # only used by tools that fetch via get_client(ctx)
return ctx
@pytest.fixture
def patch_get_client(mocker):
"""Replace ``get_client`` in the webdav server module with a mock."""
def _install(client):
async def fake_get_client(ctx):
return client
mocker.patch(
"nextcloud_mcp_server.server.webdav.get_client",
side_effect=fake_get_client,
)
return _install
@pytest.fixture
def patch_excluded(mocker):
"""Replace ``get_excluded_file_paths`` with a fixed return value."""
def _install(excluded: set[str]):
async def fake(*_, **__):
return excluded
mocker.patch(
"nextcloud_mcp_server.server.webdav.get_excluded_file_paths",
side_effect=fake,
)
return _install
@pytest.fixture
def fake_client():
"""A NextcloudClient-shaped mock with an AsyncMock webdav attribute."""
client = SimpleNamespace()
client.webdav = AsyncMock()
return client
# ── Read / mutate guards ────────────────────────────────────────────────
async def test_read_file_raises_when_path_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fn = webdav_tools["nc_webdav_read_file"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(path="/Secret.txt", ctx=_mock_ctx(fake_client))
fake_client.webdav.read_file.assert_not_called()
async def test_read_file_passes_through_when_not_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fake_client.webdav.read_file = AsyncMock(return_value=(b"hello", "text/plain"))
fn = webdav_tools["nc_webdav_read_file"].fn
result = await fn(path="/Public/notes.md", ctx=_mock_ctx(fake_client))
assert result["content"] == "hello"
fake_client.webdav.read_file.assert_awaited_once_with("/Public/notes.md")
async def test_write_file_raises_when_path_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_write_file"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(
path="/Private/note.md",
content="hi",
ctx=_mock_ctx(fake_client),
)
fake_client.webdav.write_file.assert_not_called()
async def test_delete_resource_raises_when_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fn = webdav_tools["nc_webdav_delete_resource"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(path="/Secret.txt", ctx=_mock_ctx(fake_client))
fake_client.webdav.delete_resource.assert_not_called()
async def test_create_directory_raises_when_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_create_directory"].fn
with pytest.raises(ToolError, match="is or is inside"):
await fn(path="/Private/sub", ctx=_mock_ctx(fake_client))
fake_client.webdav.create_directory.assert_not_called()
async def test_move_resource_blocks_excluded_source(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fn = webdav_tools["nc_webdav_move_resource"].fn
with pytest.raises(ToolError, match="source"):
await fn(
source_path="/Secret.txt",
destination_path="/Public/x.txt",
ctx=_mock_ctx(fake_client),
)
fake_client.webdav.move_resource.assert_not_called()
async def test_move_resource_blocks_excluded_destination_exact_match(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
"""Destination check must trip on an *exact* match, not just a prefix.
Regression guard for review #764: previously the message said "is
inside" but is_path_excluded also matches exact paths.
"""
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_move_resource"].fn
with pytest.raises(ToolError, match="is or is inside"):
await fn(
source_path="/Public/x.txt",
destination_path="/Private",
ctx=_mock_ctx(fake_client),
)
async def test_copy_resource_blocks_excluded_source(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fn = webdav_tools["nc_webdav_copy_resource"].fn
with pytest.raises(ToolError, match="source"):
await fn(
source_path="/Secret.txt",
destination_path="/Public/copy.txt",
ctx=_mock_ctx(fake_client),
)
fake_client.webdav.copy_resource.assert_not_called()
async def test_copy_resource_blocks_excluded_destination_descendant(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_copy_resource"].fn
with pytest.raises(ToolError, match="is or is inside"):
await fn(
source_path="/Public/x.txt",
destination_path="/Private/copy.txt",
ctx=_mock_ctx(fake_client),
)
# ── Listing / search filtering ──────────────────────────────────────────
async def test_list_directory_filters_excluded_children(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Public/Secret.txt"})
fake_client.webdav.list_directory = AsyncMock(
return_value=[
{
"path": "/Public/Secret.txt",
"name": "Secret.txt",
"is_directory": False,
},
{
"path": "/Public/visible.md",
"name": "visible.md",
"is_directory": False,
},
]
)
fn = webdav_tools["nc_webdav_list_directory"].fn
result = await fn(path="/Public", ctx=_mock_ctx(fake_client))
assert [f.path for f in result.files] == ["/Public/visible.md"]
async def test_list_directory_raises_when_listed_path_itself_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
"""The early guard prevents the round-trip to Nextcloud and signals
the access denial, instead of silently returning an empty listing
(review #764)."""
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_list_directory"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(path="/Private", ctx=_mock_ctx(fake_client))
fake_client.webdav.list_directory.assert_not_called()
async def test_search_files_filters_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fake_client.webdav.search_files = AsyncMock(
return_value=[
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
{"path": "/notes.md", "name": "notes.md", "is_directory": False},
]
)
fn = webdav_tools["nc_webdav_search_files"].fn
result = await fn(ctx=_mock_ctx(fake_client), name_pattern="%.%")
assert [r.path for r in result.results] == ["/notes.md"]
async def test_find_by_name_filters_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fake_client.webdav.find_by_name = AsyncMock(
return_value=[
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
{"path": "/visible.txt", "name": "visible.txt", "is_directory": False},
]
)
fn = webdav_tools["nc_webdav_find_by_name"].fn
result = await fn(pattern="%.txt", ctx=_mock_ctx(fake_client))
assert [r.path for r in result.results] == ["/visible.txt"]
async def test_find_by_type_filters_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fake_client.webdav.find_by_type = AsyncMock(
return_value=[
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
{"path": "/visible.txt", "name": "visible.txt", "is_directory": False},
]
)
fn = webdav_tools["nc_webdav_find_by_type"].fn
result = await fn(mime_type="text/plain", ctx=_mock_ctx(fake_client))
assert [r.path for r in result.results] == ["/visible.txt"]
async def test_list_favorites_filters_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Secret.txt"})
fake_client.webdav.list_favorites = AsyncMock(
return_value=[
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
{"path": "/visible.txt", "name": "visible.txt", "is_directory": False},
]
)
fn = webdav_tools["nc_webdav_list_favorites"].fn
result = await fn(ctx=_mock_ctx(fake_client))
assert [r.path for r in result.results] == ["/visible.txt"]
# ── Search-tool scope guards (review #764) ──────────────────────────────
async def test_search_files_raises_when_scope_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
"""Mirror the ``list_directory`` early guard so the four search tools
cannot silently return an empty result for an excluded ``scope``."""
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_search_files"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(ctx=_mock_ctx(fake_client), scope="/Private", name_pattern="%.txt")
fake_client.webdav.search_files.assert_not_called()
async def test_find_by_name_raises_when_scope_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_find_by_name"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(pattern="%.txt", scope="/Private", ctx=_mock_ctx(fake_client))
fake_client.webdav.find_by_name.assert_not_called()
async def test_find_by_type_raises_when_scope_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_find_by_type"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(mime_type="text/plain", scope="/Private", ctx=_mock_ctx(fake_client))
fake_client.webdav.find_by_type.assert_not_called()
async def test_list_favorites_raises_when_scope_excluded(
webdav_tools, fake_client, patch_get_client, patch_excluded
):
patch_get_client(fake_client)
patch_excluded({"Private"})
fn = webdav_tools["nc_webdav_list_favorites"].fn
with pytest.raises(ToolError, match="excluded tag"):
await fn(ctx=_mock_ctx(fake_client), scope="/Private")
fake_client.webdav.list_favorites.assert_not_called()