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:
co-authored by
Claude Opus 4.8
parent
6206f4a634
commit
b1fac2d7a8
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user