fix(search): verify shared files by global file id (ACL-aware)

The ACL-aware vector filter (PR #813) expands a user's search to documents
whose owner shared them, but verify-on-read still re-checked each file by
PATH under the *searching* user's WebDAV root. Nextcloud mounts received
shares at the recipient's root by basename, so a nested shared file (e.g.
owner's /docs/report.pdf) 404s for the recipient and was silently dropped —
defeating the filter for everything but root-level files.

Verify files by their global Nextcloud file id instead (the file doc_id IS
that id): WebDAVClient.get_file_info_by_id was insufficient (the dav/meta
endpoint only resolves the user's own storage, not shares), so add
WebDAVClient.file_accessible_by_id which runs a WebDAV SEARCH over the user's
whole tree (incl. mounted shares) filtered on oc:fileid. Empirically this
resolves owned, directly-shared, and folder-shared files; an empty result is
a definitive drop, transport errors are kept as transient.

- search/verification.py: _verify_files now checks file_accessible_by_id.
- client/webdav.py: add file_accessible_by_id (SEARCH by fileid).
- tests/integration/test_acl_owner_filter.py: filter matrix vs real Qdrant.
- tests/integration/test_acl_shared_search.py: real-Nextcloud share -> search.
- tests/integration/test_verify_on_read.py: nested shared file kept for the
  recipient; unshared file dropped.
- tests/unit/search/test_verification.py: id-based verifier semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-28 23:05:40 +02:00
co-authored by Claude Opus 4.8
parent dc0653c415
commit bf35200bab
8 changed files with 586 additions and 920 deletions
+116 -1
View File
@@ -23,10 +23,11 @@ behaviour separately.
"""
import logging
import os
import uuid
import pytest
from httpx import HTTPStatusError
from httpx import BasicAuth, HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.search import verification
@@ -48,6 +49,28 @@ def _result_for_note(note_id: int) -> SearchResult:
)
def _result_for_file(file_id: int, path: str) -> SearchResult:
# Mirrors what the algorithm layer propagates: doc_id IS the global file id,
# ``path`` is carried in metadata (owner-relative) for log context only.
return SearchResult(
id=file_id,
doc_type="file",
title=path.split("/")[-1],
excerpt="...",
score=0.9,
metadata={"path": path},
)
def _user_client(username: str, password: str) -> NextcloudClient:
return NextcloudClient(
base_url=os.environ["NEXTCLOUD_HOST"],
username=username,
auth=BasicAuth(username, password),
password=password,
)
async def test_verify_keeps_accessible_note(
nc_client: NextcloudClient, temporary_note: dict, mocker
):
@@ -170,3 +193,95 @@ async def test_verify_dedupes_chunks_of_same_document(
assert dropped_count == 0
# ...but verification only fetched the note ONCE
assert spy_get_note.await_count == 1
# ---------------------------------------------------------------------------
# File verifier — cross-user shared access (ACL-aware search, PR #813)
# ---------------------------------------------------------------------------
#
# These exercise the verifier fix that makes ACL-aware search actually work
# end-to-end: a file an owner shared with another user must survive
# verify-on-read for the *recipient*, even when it lives in a subfolder of the
# owner's tree (Nextcloud mounts received shares at the recipient's root by
# basename, so the owner-relative path does NOT resolve under the recipient's
# root). The fix verifies by global file id, which is ACL-aware.
@pytest.fixture
async def alice_bob_clients(test_users_setup):
"""Direct NextcloudClients for alice (owner) and bob (recipient)."""
alice = _user_client("alice", test_users_setup["alice"]["password"])
bob = _user_client("bob", test_users_setup["bob"]["password"])
try:
yield alice, bob
finally:
await alice._client.aclose()
await bob._client.aclose()
async def test_verify_keeps_nested_file_shared_with_recipient(
alice_bob_clients, mocker
):
"""The PR #813 acceptance check at the verifier layer.
Alice owns a file in a *subfolder* and shares it with Bob. Verifying the
result as Bob must KEEP it — proving the id-based check sees the share.
A path-based check (the old behaviour) would 404 here and wrongly drop it.
"""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
alice, bob = alice_bob_clients
suffix = uuid.uuid4().hex[:8]
test_dir = f"acl_verify_{suffix}"
nested_dir = f"{test_dir}/reports"
shared_path = f"{nested_dir}/shared.txt"
await alice.webdav.create_directory(test_dir)
await alice.webdav.create_directory(nested_dir)
await alice.webdav.write_file(shared_path, b"alice's shared report", "text/plain")
file_id = (await alice.webdav.get_file_info(shared_path))["id"]
await alice.sharing.create_share(
path=f"/{shared_path}", share_with="bob", share_type=0, permissions=1
)
try:
kept, dropped_count = await verify_search_results(
bob, [_result_for_file(file_id, shared_path)]
)
assert [r.id for r in kept] == [file_id], (
"a nested file shared with bob must pass verification for bob"
)
assert dropped_count == 0
spy_evict.assert_not_awaited()
finally:
await alice.webdav.delete_resource(test_dir)
async def test_verify_drops_unshared_file_for_other_user(alice_bob_clients, mocker):
"""Negative control: a file Alice did NOT share is inaccessible to Bob and
must be dropped + scheduled for eviction under his identity."""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
alice, bob = alice_bob_clients
suffix = uuid.uuid4().hex[:8]
test_dir = f"acl_verify_priv_{suffix}"
private_path = f"{test_dir}/private.txt"
await alice.webdav.create_directory(test_dir)
await alice.webdav.write_file(private_path, b"alice's private note", "text/plain")
file_id = (await alice.webdav.get_file_info(private_path))["id"]
try:
kept, dropped_count = await verify_search_results(
bob, [_result_for_file(file_id, private_path)]
)
assert kept == [], "an unshared file must not pass verification for bob"
assert dropped_count == 1
spy_evict.assert_awaited_once_with(file_id, "file", bob.username)
finally:
await alice.webdav.delete_resource(test_dir)