fix(webdav): address PR #764 review round 2

Addresses the four points raised in the automated review on PR #764:

1. Scope guards on the four search tools (search_files, find_by_name,
   find_by_type, list_favorites) so an excluded `scope` raises ToolError
   instead of silently returning an empty result. Previously an LLM
   could probe the asymmetry between list_directory (raises) and the
   search tools (silent) to infer that an excluded directory exists.
   The 4 search tools now mirror the early-guard pattern from
   list_directory and avoid an unnecessary upstream query for known-
   excluded scopes.

2. Concurrent per-tag resolution in get_excluded_file_paths via
   anyio.create_task_group(). Previously the 2N network calls (1
   PROPFIND + 1 REPORT per tag) ran serially. Per-tag fail-open
   behaviour is preserved by extracting _resolve_one_tag, which
   swallows its own exceptions so a single tag failure does not abort
   the surrounding task group.

3. WebDAVClient.get_tag_by_name and get_files_by_tag now route through
   _make_request, inheriting the @retry_on_429 decorator. Previously
   they bypassed it; with tag exclusion invoked on every WebDAV tool
   call, a transient 429 from the systemtags endpoint was hitting the
   fail-open path instead of being transparently retried.

4. Test coverage: 6 new tests in test_webdav_tools_exclusion.py (4
   scope-guard, 2 missing filter tests for find_by_type and
   list_favorites) and 2 new tests in test_tag_exclusion.py (a
   concurrency proof using an event-barrier that would deadlock under
   sequential execution, and a fail-open-under-task-group test with
   order-independent side_effect callables).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-06 19:15:39 +02:00
co-authored by Claude Opus 4.7
parent a6c188abbb
commit d179ca8c8b
5 changed files with 257 additions and 45 deletions
+80
View File
@@ -10,6 +10,7 @@ import logging
import os
from unittest.mock import AsyncMock, patch
import anyio
import pytest
from nextcloud_mcp_server.config import _reload_config
@@ -218,3 +219,82 @@ class TestGetExcludedFilePaths:
assert result == {"Secret.txt", "Private", "Other/notes.md"}
assert webdav.get_files_by_tag.await_count == 2
@pytest.mark.unit
async def test_resolves_tags_concurrently(self, mocker):
"""Per-tag resolution must run under a task group so the 2N
network calls overlap (PR review #764). The barrier event below
only completes if all three tags' ``get_tag_by_name`` invocations
are in flight simultaneously — sequential resolution would block
the first task forever and trip ``fail_after``.
"""
tags = ["a", "b", "c"]
mocker.patch(
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names",
return_value=tags,
)
all_started = anyio.Event()
started_count = 0
async def fake_get_tag(tag_name: str):
nonlocal started_count
started_count += 1
if started_count == len(tags):
all_started.set()
with anyio.fail_after(2.0):
await all_started.wait()
return {"id": ord(tag_name), "name": tag_name}
async def fake_get_files(tag_id: int):
return [{"path": f"/tag-{tag_id}.txt", "is_directory": False}]
webdav = AsyncMock()
webdav.get_tag_by_name = AsyncMock(side_effect=fake_get_tag)
webdav.get_files_by_tag = AsyncMock(side_effect=fake_get_files)
with anyio.fail_after(5.0):
result = await get_excluded_file_paths(webdav)
assert result == {f"tag-{ord(t)}.txt" for t in tags}
@pytest.mark.unit
async def test_fail_open_under_task_group(self, mocker, caplog):
"""One tag failing must not abort sibling tasks in the task group
(PR review #764). Uses callable side_effects keyed by tag name so
the assertion is independent of the order in which the task group
schedules the per-tag coroutines.
"""
mocker.patch(
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names",
return_value=["good-1", "broken", "good-2"],
)
async def fake_get_tag(tag_name: str):
if tag_name == "broken":
raise RuntimeError("upstream 503")
return {"id": hash(tag_name) & 0xFFFF, "name": tag_name}
async def fake_get_files(tag_id: int):
# Map tag_id back to a deterministic path.
return [{"path": f"/tagged-by-{tag_id}.txt", "is_directory": False}]
webdav = AsyncMock()
webdav.get_tag_by_name = AsyncMock(side_effect=fake_get_tag)
webdav.get_files_by_tag = AsyncMock(side_effect=fake_get_files)
caplog.set_level(
logging.WARNING, logger="nextcloud_mcp_server.server.tag_exclusion"
)
result = await get_excluded_file_paths(webdav)
# Both healthy tags must contribute one path each; the broken
# tag is silently skipped.
good_1_id = hash("good-1") & 0xFFFF
good_2_id = hash("good-2") & 0xFFFF
assert result == {
f"tagged-by-{good_1_id}.txt",
f"tagged-by-{good_2_id}.txt",
}
assert "Tag exclusion lookup failed" in caplog.text
assert "broken" in caplog.text
+93
View File
@@ -288,3 +288,96 @@ async def test_find_by_name_filters_excluded(
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()