- 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>
401 lines
13 KiB
Python
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()
|