Files
mcp-nextcloud/tests/unit/client/test_webdav.py
T
Chris CoutinhoandClaude Opus 4.7 22ed9e99a0 feat(webdav): add tag-based file exclusion (#710)
Hide sensitive files/folders from the WebDAV MCP tool surface by
tagging them with a configured Nextcloud system tag. Defence-in-depth
control for users who connect LLMs to accounts holding contracts,
medical records, credentials, etc.

A new EXCLUDED_TAGS env var (comma-separated tag names, empty by
default) gates an exclusion layer that runs at the start of every
WebDAV tool call: tag names are resolved to tag IDs, those IDs are
expanded to the set of tagged paths, then listings/searches are
filtered and read/write/delete/move/copy operations on excluded paths
raise ToolError. Tagged folders exclude their descendants via prefix
match. Empty EXCLUDED_TAGS disables the feature entirely.

The threat model is preventing accidental data exfiltration via the
LLM tool surface — not hiding files from a determined operator. The
docs explicitly recommend creating exclusion tags with
user_assignable=false so the credentials the MCP server uses cannot
remove the tag.

Implementation:

- config.py: add `excluded_tags` to _DEFAULTS, Settings, and the
  _field_map alongside other comma-separated env vars.
- client/webdav.py: get_files_by_tag now requests <d:resourcetype/>
  and surfaces is_directory so tagged directories can recursively
  exclude descendants.
- server/tag_exclusion.py (new): get_excluded_tag_names,
  get_excluded_file_paths, is_path_excluded.
- server/webdav.py: exclusion guards in all 11 WebDAV tools;
  read/write/create/delete/move/copy raise ToolError, list/search
  tools silently filter excluded entries. Existing f-string log
  calls converted to lazy %-style.
- tests: 17 new unit tests covering path-matching edge cases
  (shared-prefix non-match, descendants of excluded dirs), tag-name
  parsing, and get_excluded_file_paths with mocked WebDAV; 1 new
  client test asserting <d:resourcetype/> -> is_directory parsing.
- docs/configuration.md: new "Tag-Based File Exclusion" section with
  per-tool effect table, security guidance, and per-call cost note.
- README.md: feature mention under Key Features.

Closes #710.

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

417 lines
14 KiB
Python

"""Unit tests for WebDAV client."""
from unittest.mock import AsyncMock
import pytest
from nextcloud_mcp_server.client.webdav import WebDAVClient
@pytest.mark.unit
async def test_find_by_tag_calls_search_files(mocker):
"""Test that find_by_tag constructs correct search query."""
# Create mock HTTP client
mock_http_client = AsyncMock()
# Create WebDAVClient instance
client = WebDAVClient(mock_http_client, "testuser")
# Mock the search_files method to avoid actual HTTP calls
mock_search_files = mocker.patch.object(client, "search_files", return_value=[])
# Call find_by_tag
await client.find_by_tag("vector-index")
# Verify search_files was called with correct parameters
mock_search_files.assert_called_once()
call_args = mock_search_files.call_args
# Check that the where_conditions contains the tag name
assert "vector-index" in call_args.kwargs["where_conditions"]
assert "<oc:tags/>" in call_args.kwargs["where_conditions"]
assert "<d:like>" in call_args.kwargs["where_conditions"]
# Check that tags property is requested
assert "tags" in call_args.kwargs["properties"]
@pytest.mark.unit
async def test_find_by_tag_with_scope_and_limit(mocker):
"""Test find_by_tag passes scope and limit parameters."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
mock_search_files = mocker.patch.object(client, "search_files", return_value=[])
# Call with scope and limit
await client.find_by_tag("test-tag", scope="Documents", limit=10)
# Verify parameters were passed through
call_args = mock_search_files.call_args
assert call_args.kwargs["scope"] == "Documents"
assert call_args.kwargs["limit"] == 10
@pytest.mark.unit
def test_parse_search_response_with_tags(mocker):
"""Test that _parse_search_response correctly parses tags."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock XML response with tags (comma-separated format)
xml_content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Documents/test.pdf</d:href>
<d:propstat>
<d:prop>
<d:displayname>test.pdf</d:displayname>
<d:getcontenttype>application/pdf</d:getcontenttype>
<d:getcontentlength>1024</d:getcontentlength>
<d:getetag>"abc123"</d:getetag>
<oc:fileid>12345</oc:fileid>
<oc:tags>vector-index,important</oc:tags>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
# Parse the response
results = client._parse_search_response(xml_content, scope="Documents")
# Verify tags were parsed correctly
assert len(results) == 1
assert "tags" in results[0]
assert results[0]["tags"] == ["vector-index", "important"]
assert results[0]["name"] == "test.pdf"
assert results[0]["content_type"] == "application/pdf"
@pytest.mark.unit
def test_parse_search_response_with_empty_tags(mocker):
"""Test that _parse_search_response handles files without tags."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock XML response without tags
xml_content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Documents/test.txt</d:href>
<d:propstat>
<d:prop>
<d:displayname>test.txt</d:displayname>
<d:getcontenttype>text/plain</d:getcontenttype>
<oc:tags/>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
# Parse the response
results = client._parse_search_response(xml_content, scope="Documents")
# Verify tags field is empty list
assert len(results) == 1
assert "tags" in results[0]
assert results[0]["tags"] == []
@pytest.mark.unit
async def test_get_file_info_returns_file_details(mocker):
"""Test that get_file_info returns file info including file ID."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock PROPFIND response
mock_response = AsyncMock()
mock_response.status_code = 207
mock_response.content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Documents/test.pdf</d:href>
<d:propstat>
<d:prop>
<oc:fileid>12345</oc:fileid>
<d:displayname>test.pdf</d:displayname>
<d:getcontentlength>1024</d:getcontentlength>
<d:getcontenttype>application/pdf</d:getcontenttype>
<d:getlastmodified>Sat, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"abc123"</d:getetag>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
mock_response.raise_for_status = mocker.Mock()
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call get_file_info
result = await client.get_file_info("Documents/test.pdf")
# Verify result
assert result is not None
assert result["id"] == 12345
assert result["name"] == "test.pdf"
assert result["path"] == "Documents/test.pdf"
assert result["content_type"] == "application/pdf"
assert result["size"] == 1024
assert result["etag"] == "abc123"
assert result["is_directory"] is False
@pytest.mark.unit
async def test_get_file_info_raises_on_404(mocker):
"""get_file_info now raises HTTPStatusError on 404 (was: returned None).
The contract was widened so verify-on-read can distinguish a definitive
404 from an ambiguous malformed-PROPFIND response. Callers that want
"absent → None" semantics should catch HTTPStatusError and check the
status code themselves.
"""
from httpx import HTTPStatusError, Response
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 response
mock_response = mocker.Mock(spec=Response)
mock_response.status_code = 404
mock_http_client.request = AsyncMock(
side_effect=HTTPStatusError(
"Not Found", request=mocker.Mock(), response=mock_response
)
)
with pytest.raises(HTTPStatusError) as exc_info:
await client.get_file_info("nonexistent.pdf")
assert exc_info.value.response.status_code == 404
@pytest.mark.unit
async def test_create_tag_creates_system_tag(mocker):
"""Test that create_tag creates a system tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock WebDAV response with Content-Location header
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
mock_response.raise_for_status = mocker.Mock()
mock_http_client.post = AsyncMock(return_value=mock_response)
# Call create_tag
result = await client.create_tag("vector-index")
# Verify result
assert result["id"] == 42
assert result["name"] == "vector-index"
assert result["userVisible"] is True
assert result["userAssignable"] is True
# Verify API call
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[0][0] == "/remote.php/dav/systemtags/"
assert call_args[1]["json"]["name"] == "vector-index"
assert call_args[1]["json"]["userVisible"] is True
assert call_args[1]["json"]["userAssignable"] is True
@pytest.mark.unit
async def test_get_or_create_tag_returns_existing_tag(mocker):
"""Test that get_or_create_tag returns existing tag without creating."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock existing tag
mocker.patch.object(
client,
"get_tag_by_name",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
mock_create = mocker.patch.object(client, "create_tag")
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify existing tag returned without creating
assert result["id"] == 42
mock_create.assert_not_called()
@pytest.mark.unit
async def test_get_or_create_tag_creates_new_tag(mocker):
"""Test that get_or_create_tag creates tag when not found."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock no existing tag
mocker.patch.object(client, "get_tag_by_name", return_value=None)
mocker.patch.object(
client,
"create_tag",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify tag was created
assert result["id"] == 42
client.create_tag.assert_called_once_with("vector-index", True, True)
@pytest.mark.unit
async def test_assign_tag_to_file_success(mocker):
"""Test that assign_tag_to_file assigns tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 201 Created response
mock_response = AsyncMock()
mock_response.status_code = 201
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "PUT"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_assign_tag_to_file_already_assigned(mocker):
"""Test that assign_tag_to_file handles already assigned (409) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 409 Conflict response (already assigned)
mock_response = AsyncMock()
mock_response.status_code = 409
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result (should succeed even with 409)
assert result is True
@pytest.mark.unit
async def test_remove_tag_from_file_success(mocker):
"""Test that remove_tag_from_file removes tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 204 No Content response
mock_response = AsyncMock()
mock_response.status_code = 204
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "DELETE"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_remove_tag_from_file_not_assigned(mocker):
"""Test that remove_tag_from_file handles not assigned (404) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 Not Found response (tag wasn't assigned)
mock_response = AsyncMock()
mock_response.status_code = 404
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result (should succeed even with 404)
assert result is True
@pytest.mark.unit
async def test_get_files_by_tag_detects_directories(mocker):
"""get_files_by_tag must flag tagged folders via <d:resourcetype/>.
Tagged folders need ``is_directory=True`` so the tag-exclusion layer
(issue #710) can hide their descendants.
"""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Two-entry response: one regular file, one collection (folder).
xml_content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Secret.txt</d:href>
<d:propstat>
<d:prop>
<oc:fileid>101</oc:fileid>
<d:displayname>Secret.txt</d:displayname>
<d:getcontentlength>42</d:getcontentlength>
<d:getcontenttype>text/plain</d:getcontenttype>
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"abc"</d:getetag>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/testuser/Private/</d:href>
<d:propstat>
<d:prop>
<oc:fileid>102</oc:fileid>
<d:displayname>Private</d:displayname>
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"def"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
mock_response = AsyncMock()
mock_response.content = xml_content
mock_response.raise_for_status = mocker.Mock()
mock_http_client.request = AsyncMock(return_value=mock_response)
files = await client.get_files_by_tag(42)
assert len(files) == 2
by_path = {f["path"]: f for f in files}
assert by_path["/Secret.txt"]["is_directory"] is False
assert by_path["/Private/"]["is_directory"] is True
# Sanity-check the REPORT body asks for resourcetype.
call_args = mock_http_client.request.call_args
assert "<d:resourcetype/>" in call_args.kwargs["content"]
assert "<oc:systemtag>42</oc:systemtag>" in call_args.kwargs["content"]