fix(search): address PR #813 review (viz verify-on-read, owners cache, docs)

- viz_routes: run verify_search_results before returning results. After the
  accessible_owners expansion the viz can surface OTHER users' shared docs, so
  it must drop ones the caller can no longer access (revoked share) — same as
  the nc_semantic_search tool path. (Blocking review item.)
- access_filter: cache list_accessible_owners per user for 30s to keep the OCS
  shares round-trip off the search hot path (failures aren't cached); document
  the single-page OCS limitation; add a clear_accessible_owners_cache() test
  helper. Comment the empty-accessible_owners MatchAny([]) edge case.
- verification: comment why cross-user eviction is a deliberate no-op (eviction
  is scoped to the querying user's id, so a recipient's revoked access never
  deletes the owner's points; the recipient self-heals via accessible_owners).
- algorithms: declare SearchResult.original_score (set by the viz route) so the
  now-precisely-typed result list type-checks.
- tests: cross-user eviction-no-op safety test; autouse owners-cache reset in
  the access_filter + shared-search tests; replace async-no-await qdrant fakes
  with AsyncMock (clears SonarCloud S7503).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-29 02:15:44 +02:00
co-authored by Claude Opus 4.8
parent 6206f4a634
commit b1fac2d7a8
8 changed files with 122 additions and 16 deletions
+3 -4
View File
@@ -15,6 +15,8 @@ real-Nextcloud flow (share + verify-on-read) lives in
``test_acl_shared_search.py``.
"""
from unittest.mock import AsyncMock
import pytest
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
@@ -82,12 +84,9 @@ async def seeded_collection(monkeypatch):
await client.upsert(collection_name=collection, points=points, wait=True)
# Point the algorithm at the in-memory client + deterministic embeddings.
async def _fake_get_qdrant_client():
return client
monkeypatch.setattr(
"nextcloud_mcp_server.search.semantic.get_qdrant_client",
_fake_get_qdrant_client,
AsyncMock(return_value=client),
)
monkeypatch.setattr(
"nextcloud_mcp_server.search.semantic.get_embedding_service",
+16 -5
View File
@@ -23,6 +23,7 @@ proves the real share → accessible_owners → filter → verify chain.
import os
import uuid
from unittest.mock import AsyncMock
import pytest
from httpx import BasicAuth
@@ -32,12 +33,25 @@ from qdrant_client.models import Distance, PointStruct, VectorParams
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import SimpleEmbeddingProvider
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
from nextcloud_mcp_server.search.access_filter import (
clear_accessible_owners_cache,
list_accessible_owners,
)
from nextcloud_mcp_server.search.semantic import SemanticSearchAlgorithm
from nextcloud_mcp_server.search.verification import verify_search_results
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def _reset_owners_cache():
"""Reset the process-global accessible-owners cache around each test so a
real OCS share created in a fixture isn't masked by a stale cached entry."""
clear_accessible_owners_cache()
yield
clear_accessible_owners_cache()
_DOC_TEXT = "Confidential quarterly infrastructure budget and capacity plan"
@@ -127,12 +141,9 @@ async def seeded_semantic(monkeypatch, shared_file):
wait=True,
)
async def _fake_get_qdrant_client():
return client
monkeypatch.setattr(
"nextcloud_mcp_server.search.semantic.get_qdrant_client",
_fake_get_qdrant_client,
AsyncMock(return_value=client),
)
monkeypatch.setattr(
"nextcloud_mcp_server.search.semantic.get_embedding_service",
+10
View File
@@ -8,10 +8,20 @@ import pytest
from nextcloud_mcp_server.search.access_filter import (
build_ownership_filter,
clear_accessible_owners_cache,
list_accessible_owners,
)
@pytest.fixture(autouse=True)
def _reset_owners_cache():
"""The accessible-owners cache is process-global; reset it around each test
so the shared "alice" user_id can't leak cached results between tests."""
clear_accessible_owners_cache()
yield
clear_accessible_owners_cache()
class TestListAccessibleOwners:
@pytest.mark.unit
async def test_includes_self_even_with_no_shares(self) -> None:
+31
View File
@@ -874,6 +874,37 @@ async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
spy_evict.assert_awaited_once_with("99", "note", "alice")
@pytest.mark.unit
async def test_verify_evicts_cross_user_file_under_querying_user_id(mocker):
"""A shared file the recipient can no longer access is evicted under the
QUERYING user's id, never the owner's.
This guards the cross-user eviction no-op: a point owned by alice
(user_id=alice) surfaced to bob via accessible_owners and then found
inaccessible must be evicted with user_id=bob — which deletes nothing of
alice's (her points carry user_id=alice). So a recipient's revoked access
can never delete the owner's index entries; bob's view self-heals via
list_accessible_owners instead. A future change that evicted under the
owner's id would corrupt the owner's index, and this test would catch it.
"""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
webdav_client = SimpleNamespace(
file_accessible_by_id=mocker.AsyncMock(return_value=False)
)
client = SimpleNamespace(webdav=webdav_client, username="bob")
kept, dropped_count = await verify_search_results(
client,
[_make_result(777, doc_type="file", metadata={"path": "shared.txt"})],
)
assert kept == []
assert dropped_count == 1
spy_evict.assert_awaited_once_with("777", "file", "bob")
@pytest.mark.unit
async def test_verify_search_results_fire_and_forget_eviction(mocker):
"""When eviction_task_group is provided, eviction does not block the response.