Resolve the latest PR-review comment on the verify-on-read tag-gate work:
- tests: stringify note IDs in test_verify_on_read.py so SearchResult.id
matches production (scanner stringifies all IDs on write) — helper and the
keeps/deleted/mixed/dedupe assertions (blocking).
- tests: make the unshared-file negative control a PDF so the drop is
unambiguously "unshared", not a mime_type_filter mismatch.
- config: add Validator("VECTOR_SYNC_PDF_TAG", len_min=1) — an empty tag name
would make find_files_by_tag("") misbehave in the verifier and scanner.
- verification: correct the _verify_files comment — two batch fetches (tag
REPORT + EXCLUDED_TAGS lookup) are held under one semaphore slot; the
pure-Python intersection runs outside it.
- tests: de-duplicate the minimal-PDF constant into a shared PDF_BYTES in
tests/integration/conftest.py, imported by both integration modules.
Verified: ruff/format/ty/unit all green; the two integration modules
(10 tests) pass against a local Nextcloud (app-only, no MCP profile needed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""End-to-end ACL-aware semantic search against a real Nextcloud (PR #813).
|
|
|
|
This is the card-120 acceptance criterion exercised across the *new* code
|
|
paths together:
|
|
|
|
1. ``list_accessible_owners`` resolves the querying user's real OCS shares into
|
|
the set of owner UIDs they may search.
|
|
2. ``SemanticSearchAlgorithm`` applies the expanded ownership filter in Qdrant.
|
|
3. ``verify_search_results`` re-checks each hit against real Nextcloud
|
|
(ACL-aware, by global file id).
|
|
|
|
Qdrant is in-memory and seeded directly with one point owned by *alice* — this
|
|
deliberately stands in for the background scanner (whose only relevant change
|
|
is writing ``owner_id`` into the payload, covered separately). Nextcloud itself
|
|
is real, so the share lookup (step 1) and the verification (step 3) exercise
|
|
the live OCS Sharing + WebDAV APIs. The result: bob, with whom alice shared the
|
|
file, finds it without having indexed anything; diana, with no share, does not.
|
|
|
|
The pure-filter matrix lives in ``test_acl_owner_filter.py`` and the
|
|
verification layer in ``test_verify_on_read.py``; this test is the glue that
|
|
proves the real share → accessible_owners → filter → verify chain.
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from httpx import BasicAuth
|
|
from qdrant_client import AsyncQdrantClient
|
|
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 (
|
|
clear_accessible_owners_cache,
|
|
list_accessible_owners,
|
|
)
|
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
|
from nextcloud_mcp_server.search.semantic import SemanticSearchAlgorithm
|
|
from nextcloud_mcp_server.search.verification import verify_search_results
|
|
from tests.integration.conftest import PDF_BYTES
|
|
|
|
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"
|
|
|
|
|
|
def _user_client(username: str, password: str) -> NextcloudClient:
|
|
return NextcloudClient(
|
|
base_url=os.environ["NEXTCLOUD_HOST"],
|
|
username=username,
|
|
auth=BasicAuth(username, password),
|
|
password=password,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def acl_users(test_users_setup):
|
|
"""alice (owner), bob (recipient), diana (no access) direct clients."""
|
|
clients = {
|
|
name: _user_client(name, test_users_setup[name]["password"])
|
|
for name in ("alice", "bob", "diana")
|
|
}
|
|
try:
|
|
yield clients
|
|
finally:
|
|
for c in clients.values():
|
|
await c._client.aclose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def shared_file(acl_users):
|
|
"""alice creates a nested PDF, tags it ``vector-index``, and shares it with
|
|
bob (not diana).
|
|
|
|
The vector-index tag is required because verify-on-read now gates file
|
|
results on current tag membership (in addition to ACL access). The tag is
|
|
created userVisible so the owner's assignment surfaces in the recipient's
|
|
systemtag REPORT — this fixture is the live check of that assumption.
|
|
|
|
Yields (file_id, owner_relative_path); cleans up the directory after.
|
|
"""
|
|
alice = acl_users["alice"]
|
|
suffix = uuid.uuid4().hex[:8]
|
|
test_dir = f"acl_e2e_{suffix}"
|
|
nested = f"{test_dir}/reports"
|
|
path = f"{nested}/budget.pdf"
|
|
|
|
await alice.webdav.create_directory(test_dir)
|
|
await alice.webdav.create_directory(nested)
|
|
await alice.webdav.write_file(path, PDF_BYTES, "application/pdf")
|
|
file_id = (await alice.webdav.get_file_info(path))["id"]
|
|
tag = await alice.webdav.get_or_create_tag(
|
|
name=get_settings().vector_sync_pdf_tag,
|
|
user_visible=True,
|
|
user_assignable=True,
|
|
)
|
|
await alice.webdav.assign_tag_to_file(file_id, tag["id"])
|
|
await alice.sharing.create_share(
|
|
path=f"/{path}", share_with="bob", share_type=0, permissions=1
|
|
)
|
|
try:
|
|
yield file_id, path
|
|
finally:
|
|
try:
|
|
await alice.webdav.remove_tag_from_file(file_id, tag["id"])
|
|
except Exception:
|
|
pass
|
|
await alice.webdav.delete_resource(test_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
async def seeded_semantic(monkeypatch, shared_file):
|
|
"""In-memory Qdrant carrying alice's file point, wired into the algorithm.
|
|
|
|
Stands in for the background scanner: the point carries ``owner_id=alice``
|
|
exactly as the scanner now writes it.
|
|
"""
|
|
file_id, path = shared_file
|
|
provider = SimpleEmbeddingProvider(dimension=384)
|
|
client = AsyncQdrantClient(":memory:")
|
|
collection = get_settings().get_collection_name()
|
|
await client.create_collection(
|
|
collection_name=collection,
|
|
vectors_config={"dense": VectorParams(size=384, distance=Distance.COSINE)},
|
|
)
|
|
await client.upsert(
|
|
collection_name=collection,
|
|
points=[
|
|
PointStruct(
|
|
id=int(file_id),
|
|
vector={"dense": await provider.embed(_DOC_TEXT)},
|
|
payload={
|
|
"doc_id": str(file_id),
|
|
"doc_type": "file",
|
|
"owner_id": "alice",
|
|
"user_id": "alice",
|
|
"is_placeholder": False,
|
|
"file_path": path,
|
|
"title": "budget.pdf",
|
|
"excerpt": _DOC_TEXT,
|
|
"chunk_index": 0,
|
|
"total_chunks": 1,
|
|
},
|
|
)
|
|
],
|
|
wait=True,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"nextcloud_mcp_server.search.semantic.get_qdrant_client",
|
|
AsyncMock(return_value=client),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nextcloud_mcp_server.search.semantic.get_embedding_service",
|
|
lambda: provider,
|
|
)
|
|
# The cached-chunk lookups (get_chunk_with_context) read the client from the
|
|
# context module — point it at the same in-memory Qdrant.
|
|
monkeypatch.setattr(
|
|
"nextcloud_mcp_server.search.context.get_qdrant_client",
|
|
AsyncMock(return_value=client),
|
|
)
|
|
yield file_id
|
|
await client.close()
|
|
|
|
|
|
async def _search_as(user_client, file_id_unused) -> list:
|
|
"""Run the full new chain (share lookup → filter → verify) as a user."""
|
|
accessible_owners = await list_accessible_owners(
|
|
user_client.sharing, user_client.username
|
|
)
|
|
algo = SemanticSearchAlgorithm(score_threshold=0.0)
|
|
unverified = await algo.search(
|
|
query=_DOC_TEXT,
|
|
user_id=user_client.username,
|
|
limit=10,
|
|
doc_type="file",
|
|
accessible_owners=accessible_owners,
|
|
)
|
|
kept, _dropped = await verify_search_results(user_client, unverified)
|
|
return kept
|
|
|
|
|
|
async def test_recipient_finds_shared_file_without_indexing(acl_users, seeded_semantic):
|
|
"""Bob finds alice's shared file end-to-end: real share lookup expands his
|
|
accessible owners to include alice, the filter surfaces her point, and
|
|
real verification confirms both his ACL access AND that the file is still
|
|
in the vector-index tag set — all without bob indexing.
|
|
|
|
This also exercises the strict tag-gate's key assumption: a vector-index
|
|
tag alice assigned (userVisible) surfaces in bob's systemtag REPORT for a
|
|
file shared into his tree. If a future Nextcloud version stops surfacing an
|
|
owner's tag to a recipient, this assertion is where it fails first."""
|
|
file_id = seeded_semantic
|
|
# Sanity: the live OCS lookup really does expand bob to include alice.
|
|
owners = await list_accessible_owners(acl_users["bob"].sharing, "bob")
|
|
assert "alice" in owners, "OCS shared-with-me must surface alice as an owner"
|
|
|
|
kept = await _search_as(acl_users["bob"], file_id)
|
|
|
|
assert [r.id for r in kept] == [str(file_id)], (
|
|
"bob must find alice's shared file via semantic search"
|
|
)
|
|
|
|
|
|
async def test_non_recipient_does_not_find_file(acl_users, seeded_semantic):
|
|
"""Diana, with no share, never sees the file: her accessible-owners set
|
|
excludes alice, so the ownership filter drops the point before verification."""
|
|
owners = await list_accessible_owners(acl_users["diana"].sharing, "diana")
|
|
assert "alice" not in owners
|
|
|
|
kept = await _search_as(acl_users["diana"], seeded_semantic)
|
|
|
|
assert kept == [], "diana (no share) must not find alice's file"
|
|
|
|
|
|
async def test_file_accessible_by_id_resolves_shares(acl_users, shared_file):
|
|
"""Lock the verify-on-read contract directly on ``file_accessible_by_id``.
|
|
|
|
The WebDAV SEARCH-by-fileid with ``scope=""`` must resolve a file that the
|
|
caller does NOT own but which is shared with them. This is the exact check
|
|
verify-on-read depends on for shared, nested files; a Nextcloud change to
|
|
how ``scope=""`` is interpreted would otherwise silently break ACL-aware
|
|
verification. The file lives in a subfolder, so a path-based check would
|
|
404 for the recipient — only the by-id SEARCH gets it right.
|
|
"""
|
|
file_id, _path = shared_file
|
|
fid = int(file_id)
|
|
|
|
# Owner and share recipient can both reach it...
|
|
assert await acl_users["alice"].webdav.file_accessible_by_id(fid) is True
|
|
assert await acl_users["bob"].webdav.file_accessible_by_id(fid) is True
|
|
# ...the non-recipient cannot.
|
|
assert await acl_users["diana"].webdav.file_accessible_by_id(fid) is False
|
|
|
|
|
|
async def test_cross_user_file_chunk_context(acl_users, seeded_semantic):
|
|
"""End-to-end cross-user FILE chunk context: Bob (a share recipient) gets
|
|
Alice's cached chunk text, Diana (no share) gets None.
|
|
|
|
Exercises the full secure path: the ACL-aware Qdrant cached-chunk lookup
|
|
(owner_id=alice surfaces for Bob) gated by a real per-file
|
|
``file_accessible_by_id`` check against live Nextcloud. Diana fails the gate
|
|
and is denied even though the chunk is cached. Per-user types are covered by
|
|
the self-only behaviour elsewhere — this is the file path the feature adds.
|
|
"""
|
|
file_id = seeded_semantic
|
|
bob = acl_users["bob"]
|
|
diana = acl_users["diana"]
|
|
|
|
bob_owners = await list_accessible_owners(bob.sharing, "bob")
|
|
assert "alice" in bob_owners
|
|
|
|
ctx = await get_chunk_with_context(
|
|
nc_client=bob,
|
|
user_id="bob",
|
|
doc_id=str(file_id),
|
|
doc_type="file",
|
|
chunk_start=0,
|
|
chunk_end=len(_DOC_TEXT),
|
|
chunk_index=0,
|
|
total_chunks=1,
|
|
accessible_owners=bob_owners,
|
|
)
|
|
assert ctx is not None, "Bob (share recipient) must get Alice's cached chunk"
|
|
assert ctx.chunk_text == _DOC_TEXT
|
|
|
|
# Diana has no share → per-file gate denies even though the chunk is cached.
|
|
diana_owners = await list_accessible_owners(diana.sharing, "diana")
|
|
denied = await get_chunk_with_context(
|
|
nc_client=diana,
|
|
user_id="diana",
|
|
doc_id=str(file_id),
|
|
doc_type="file",
|
|
chunk_start=0,
|
|
chunk_end=len(_DOC_TEXT),
|
|
chunk_index=0,
|
|
total_chunks=1,
|
|
accessible_owners=diana_owners,
|
|
)
|
|
assert denied is None, "Diana (no share) must not get cross-user chunk context"
|