diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index 2e1a11ed..cff493a5 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -1135,7 +1135,11 @@ class WebDAVClient(BaseNextcloudClient): response = await self._make_request( "PROPFIND", "/remote.php/dav/systemtags/", - headers={"Depth": "1", "Content-Type": "text/xml"}, + headers={ + "Depth": "1", + "Content-Type": "text/xml", + "OCS-APIRequest": "true", + }, content=propfind_body, ) # Redundant after _make_request (which raises on non-2xx) but @@ -1223,7 +1227,7 @@ class WebDAVClient(BaseNextcloudClient): response = await self._make_request( "REPORT", f"{self._get_webdav_base_path()}/", - headers={"Content-Type": "text/xml"}, + headers={"Content-Type": "text/xml", "OCS-APIRequest": "true"}, content=report_body, ) # Redundant after _make_request (which raises on non-2xx) but diff --git a/nextcloud_mcp_server/server/tag_exclusion.py b/nextcloud_mcp_server/server/tag_exclusion.py index 65f256e0..8ced477b 100644 --- a/nextcloud_mcp_server/server/tag_exclusion.py +++ b/nextcloud_mcp_server/server/tag_exclusion.py @@ -46,11 +46,11 @@ async def _resolve_one_tag( ) -> None: """Resolve a single tag's paths and append them as a set to *results*. - Each task writes to a distinct slot in the shared list — append from - cooperative tasks is safe under anyio (single-threaded between - awaits) without an explicit lock. Swallows its own exceptions so a - failure for one tag does not abort the surrounding task group - (preserves fail-open per-tag semantics). + Each task appends its own set to the shared list; ``list.append`` is + atomic between cooperative yields under anyio (single-threaded + between awaits) so no explicit lock is needed. Swallows its own + exceptions so a failure for one tag does not abort the surrounding + task group (preserves fail-open per-tag semantics). """ try: tag = await webdav.get_tag_by_name(tag_name) @@ -67,6 +67,15 @@ async def _resolve_one_tag( logger.debug("Excluded tag %r does not exist — skipping", tag_name) return + if tag.get("id") is None: + # Malformed PROPFIND response: entry without + # . Skip rather than dispatch None. + logger.debug( + "Excluded tag %r has no id in PROPFIND response — skipping", + tag_name, + ) + return + try: files = await webdav.get_files_by_tag(tag["id"]) except Exception as e: diff --git a/tests/unit/test_tag_exclusion.py b/tests/unit/test_tag_exclusion.py index 9e071ded..9ed3e318 100644 --- a/tests/unit/test_tag_exclusion.py +++ b/tests/unit/test_tag_exclusion.py @@ -127,6 +127,32 @@ class TestGetExcludedFilePaths: webdav.get_tag_by_name.assert_awaited_once_with("does-not-exist") webdav.get_files_by_tag.assert_not_called() + @pytest.mark.unit + async def test_skips_tag_with_missing_id(self, mocker): + """If get_tag_by_name returns a dict with id=None (malformed + PROPFIND response — entry without ), skip + the tag rather than dispatching None + to get_files_by_tag (PR #764 review round 4).""" + mocker.patch( + "nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names", + return_value=["malformed"], + ) + webdav = AsyncMock() + webdav.get_tag_by_name = AsyncMock( + return_value={ + "id": None, + "name": "malformed", + "userVisible": True, + "userAssignable": True, + } + ) + + result = await get_excluded_file_paths(webdav) + + assert result == set() + webdav.get_tag_by_name.assert_awaited_once_with("malformed") + webdav.get_files_by_tag.assert_not_called() + @pytest.mark.unit async def test_fail_open_when_tag_lookup_raises(self, mocker, caplog): """If get_tag_by_name raises (e.g. 5xx from systemtags endpoint), diff --git a/tests/unit/test_webdav_tools_exclusion.py b/tests/unit/test_webdav_tools_exclusion.py index c1a116c9..d2cb6cf9 100644 --- a/tests/unit/test_webdav_tools_exclusion.py +++ b/tests/unit/test_webdav_tools_exclusion.py @@ -194,6 +194,23 @@ async def test_move_resource_blocks_excluded_destination_exact_match( ) +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 ):