fix(search): gate verify-on-read file results on vector-index tag membership

Verify-on-read only checked file *accessibility* (file_accessible_by_id),
never tag membership, so a file removed from the `vector-index` tag (but
still readable) kept surfacing in semantic search, and stale points only
got evicted when they happened to rank in a search's top-K.

Rework `_verify_files` to gate on current `vector-index` tag membership via
a single batch `find_files_by_tag(tag, mime_type_filter="application/pdf")`
REPORT per search (plus a one-shot EXCLUDED_TAGS lookup for exclusion-wins
parity) — exactly what the scanner indexes. A file is kept iff it is in that
set, so untagged / deleted / excluded files drop out immediately and the
existing eviction wiring reclaims their Qdrant points. The gate is strict
for all file results, own and shared. Mirrors the batch-fetch-and-intersect
shape of `_verify_news_items` (one semaphore slot, fail-open on fetch error,
malformed-id keep).

- Promote the tag name to a `vector_sync_pdf_tag` Settings field (dynaconf
  env mapping VECTOR_SYNC_PDF_TAG) used by both scanner and verifier;
  drop the scanner's direct os.getenv.
- Expose `find_files_by_tag` on NextcloudClientProtocol.
- Rewrite the file-verifier unit tests (tagged/untagged/deleted/excluded/
  fail-open/non-numeric); update the ACL + verify-on-read integration tests
  to seed tagged PDFs.
- Amend ADR-019 and the configuration.md verify-on-read latency budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-02 21:14:44 +02:00
co-authored by Claude Opus 4.8
parent 7e4b83dc94
commit d4dbf01b0a
9 changed files with 358 additions and 220 deletions
@@ -4,6 +4,22 @@
**Date**: 2026-05-01 **Date**: 2026-05-01
**Depends On**: ADR-007 (Background Vector Sync), ADR-010 (Webhook-Based Vector Sync) **Depends On**: ADR-007 (Background Vector Sync), ADR-010 (Webhook-Based Vector Sync)
> **Update (2026-06-02) — tag-aware file verification.** The `file` verifier
> described below as a per-id WebDAV check (`PROPFIND`, later
> `file_accessible_by_id`) now gates on current **`vector-index` tag
> membership** instead. It issues a single
> `find_files_by_tag(<VECTOR_SYNC_PDF_TAG>, mime_type_filter="application/pdf")`
> REPORT per search (plus a one-shot `EXCLUDED_TAGS` lookup) and keeps only
> files in that set — i.e. exactly what the scanner indexes. This is the
> "fetch once and intersect" shape (like `news_item`), not per-id fan-out, and
> it closes a gap the original design missed: a file *removed from the tag* (as
> opposed to deleted/unshared) stayed accessible and so survived the old check,
> lingering in results until the scanner's grace-period sweep. **Decision:** the
> gate is strict for all file results, own and shared — a shared file survives
> only if the owner's (userVisible) tag surfaces in the *searcher's* tag REPORT
> (validated by `tests/integration/test_acl_shared_search.py`). See
> `docs/configuration.md` → "Verify-on-Read Latency Budget" for the cost.
## Context ## Context
The vector index in Qdrant is a *recall layer*, not the source of truth. Authoritative state for every indexed document — whether a note exists, whether a file is still shared with the user, whether a deck card is on a board the user can read — lives in Nextcloud, not in our index. Whenever those two views drift, semantic search returns **ghost records**: results that point to documents the user can no longer access (or that no longer exist at all). The vector index in Qdrant is a *recall layer*, not the source of truth. Authoritative state for every indexed document — whether a note exists, whether a file is still shared with the user, whether a deck card is on a board the user can read — lives in Nextcloud, not in our index. Whenever those two views drift, semantic search returns **ghost records**: results that point to documents the user can no longer access (or that no longer exist at all).
+26 -10
View File
@@ -645,10 +645,12 @@ This adds Nextcloud round-trips to the search path that operators should be
aware of: aware of:
- **Per-search cost**: one Nextcloud round-trip per *unique* `(doc_id, doc_type)` - **Per-search cost**: one Nextcloud round-trip per *unique* `(doc_id, doc_type)`
in the result set. Chunking means a 10-result page typically references 3-5 in the result set — except `file` and `news_item`, which each batch into a
unique documents, so verification adds 3-5 round-trips. With the default single call per search regardless of how many results they contribute (see
20-way concurrency this is one parallel batch — usually under 100 ms on a the Files and News caveats below). Chunking means a 10-result page typically
healthy connection. references 3-5 unique documents, so verification adds 3-5 round-trips. With
the default 20-way concurrency this is one parallel batch — usually under
100 ms on a healthy connection.
- **Concurrency**: all verifications fan out under a shared semaphore. - **Concurrency**: all verifications fan out under a shared semaphore.
Tunable via the `VERIFICATION_CONCURRENCY` env var (settings field Tunable via the `VERIFICATION_CONCURRENCY` env var (settings field
`verification_concurrency`, default 20) — lower it if your Nextcloud `verification_concurrency`, default 20) — lower it if your Nextcloud
@@ -664,14 +666,28 @@ aware of:
search that surfaces news results. Disabling News in the indexer or running search that surfaces news results. Disabling News in the indexer or running
with a smaller backlog mitigates this; per-item paginated verification is with a smaller backlog mitigates this; per-item paginated verification is
tracked as a future improvement. tracked as a future improvement.
- **Eviction**: when verification finds a definitive miss (404 / 403), the - **Files caveat**: `file` results are gated on current **`vector-index` tag
corresponding Qdrant points are deleted in the background on a lifespan-owned membership**, not bare access — the verifier issues a single
task group — fire-and-forget, does **not** block the search response. `find_files_by_tag(<tag>, mime_type_filter="application/pdf")` REPORT per
Eviction failures are logged but never propagated; the next query will search that contains any file result (plus a one-shot `EXCLUDED_TAGS`
re-verify and re-attempt (self-healing). lookup), then keeps only files in that set. This matches exactly what the
scanner indexes, so a file removed from the tag (or deleted, or moved under
an excluded folder) drops out of results immediately rather than waiting for
the scanner sweep. The REPORT expands tagged folders via a `Depth: infinity`
SEARCH, so deployments that tag whole directory trees pay that walk once per
search; configure `VECTOR_SYNC_PDF_TAG` to change the tag name. **Shared
files**: a file an owner tagged and shared with the searcher only survives
verification if the owner's (userVisible) tag surfaces in the *searcher's*
tag REPORT.
- **Eviction**: when verification finds a definitive miss (a 404 / 403, or — for
files — absence from the tag set), the corresponding Qdrant points are deleted
in the background on a lifespan-owned task group — fire-and-forget, does
**not** block the search response. Eviction failures are logged but never
propagated; the next query will re-verify and re-attempt (self-healing).
- **Failure modes**: transient errors (5xx, network) keep results visible - **Failure modes**: transient errors (5xx, network) keep results visible
(fail open) so a flaky link does not silently shrink result pages; only (fail open) so a flaky link does not silently shrink result pages; only
*definitive* 404 / 403 drops them. *definitive* misses (404 / 403, or a file no longer in the tag set) drop them.
If the file tag REPORT itself errors, all file results are kept (fail open).
If eviction ever needs to be disabled (debugging, benchmarking), the If eviction ever needs to be disabled (debugging, benchmarking), the
`evict_on_missing=False` keyword argument on `verify_search_results()` skips `evict_on_missing=False` keyword argument on `verify_search_results()` skips
+9
View File
@@ -93,6 +93,10 @@ _DEFAULTS: dict[str, Any] = {
# leave work stuck behind the 5x-scan-interval staleness gate. # leave work stuck behind the 5x-scan-interval staleness gate.
# Escape hatch only — leave on by default. # Escape hatch only — leave on by default.
"vector_sync_orphan_sweep_enabled": True, "vector_sync_orphan_sweep_enabled": True,
# System tag that marks files for vector indexing. The scanner indexes
# files carrying this tag; verify-on-read gates results on current
# membership of this tag (ADR-019).
"vector_sync_pdf_tag": "vector-index",
# Verify-on-read concurrency cap (ADR-019) # Verify-on-read concurrency cap (ADR-019)
"verification_concurrency": 20, "verification_concurrency": 20,
# Qdrant # Qdrant
@@ -625,6 +629,10 @@ class Settings:
vector_sync_queue_max_size: int = 10000 vector_sync_queue_max_size: int = 10000
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
vector_sync_orphan_sweep_enabled: bool = True # card #101 vector_sync_orphan_sweep_enabled: bool = True # card #101
# System tag marking files for vector indexing. The scanner indexes files
# carrying this tag and verify-on-read gates results on current membership
# (ADR-019), so an untagged file drops out of search immediately.
vector_sync_pdf_tag: str = "vector-index"
# Verify-on-read concurrency (ADR-019). Cap on parallel Nextcloud # Verify-on-read concurrency (ADR-019). Cap on parallel Nextcloud
# round-trips during search-result verification fan-out. Lower this if the # round-trips during search-result verification fan-out. Lower this if the
@@ -1217,6 +1225,7 @@ def get_settings() -> Settings:
"vector_sync_queue_max_size": "VECTOR_SYNC_QUEUE_MAX_SIZE", "vector_sync_queue_max_size": "VECTOR_SYNC_QUEUE_MAX_SIZE",
"vector_sync_user_poll_interval": "VECTOR_SYNC_USER_POLL_INTERVAL", "vector_sync_user_poll_interval": "VECTOR_SYNC_USER_POLL_INTERVAL",
"vector_sync_orphan_sweep_enabled": "VECTOR_SYNC_ORPHAN_SWEEP_ENABLED", "vector_sync_orphan_sweep_enabled": "VECTOR_SYNC_ORPHAN_SWEEP_ENABLED",
"vector_sync_pdf_tag": "VECTOR_SYNC_PDF_TAG",
# Verify-on-read (ADR-019) # Verify-on-read (ADR-019)
"verification_concurrency": "VERIFICATION_CONCURRENCY", "verification_concurrency": "VERIFICATION_CONCURRENCY",
# Qdrant settings # Qdrant settings
@@ -75,6 +75,14 @@ class NextcloudClientProtocol(Protocol):
"""News client for accessing news item documents.""" """News client for accessing news item documents."""
... ...
# Top-level client helper (not a sub-client) used by verify-on-read to
# gate file results on current vector-index tag membership.
async def find_files_by_tag(
self, tag_name: str, mime_type_filter: str | None = None
) -> list[dict]:
"""Return files carrying ``tag_name`` (folders expanded by MIME)."""
...
async def get_indexed_doc_types( async def get_indexed_doc_types(
user_id: str, accessible_owners: list[str] | None = None user_id: str, accessible_owners: list[str] | None = None
+108 -70
View File
@@ -8,18 +8,21 @@ access (deleted, unshared, etc.) and lazily evicting them from the index.
Per-doc_type verifiers are registered in ``_VERIFIERS``. Each takes the Per-doc_type verifiers are registered in ``_VERIFIERS``. Each takes the
authenticated client, the (deduplicated) list of ``SearchResult``s for that authenticated client, the (deduplicated) list of ``SearchResult``s for that
doc_type, and a shared concurrency semaphore. They return the subset of doc_type, and a shared concurrency semaphore. They return the subset of
``doc_id`` values that are currently accessible. Verifiers read whatever ``doc_id`` values that are currently visible to the user. Verifiers read
metadata they need (file path, deck card board/stack ids) directly from the whatever metadata they need (e.g. deck card board/stack ids) directly from the
SearchResult — these fields are populated at index-time and propagated by SearchResult — these fields are populated at index-time and propagated by
the algorithm layer (see ``search/bm25_hybrid.py`` and ``search/semantic.py``) the algorithm layer (see ``search/bm25_hybrid.py`` and ``search/semantic.py``)
so verification adds zero extra Qdrant round-trips. so verification adds zero extra Qdrant round-trips. The file verifier is the
exception: it gates results on current ``vector-index`` tag membership via a
single batch tag REPORT (which also confirms access), so it does not read
per-result metadata.
Concurrency is bounded by a shared semaphore (default 20) so a large search Concurrency is bounded by a shared semaphore (default 20) so a large search
result page (or a multi-doc_type query) cannot exhaust the httpx connection result page (or a multi-doc_type query) cannot exhaust the httpx connection
pool or trigger Nextcloud rate limiting. The 20-slot default matches the pool or trigger Nextcloud rate limiting. The 20-slot default matches the
context-expansion convention in ``server/semantic.py``. context-expansion convention in ``server/semantic.py``.
Failure policy: Failure policy (notes / deck_card / news_item — the per-access verifiers):
- Definitive 403/404 from Nextcloud → drop the result and schedule eviction. - Definitive 403/404 from Nextcloud → drop the result and schedule eviction.
- Transient errors (5xx, network blips, unexpected exceptions) → keep the - Transient errors (5xx, network blips, unexpected exceptions) → keep the
@@ -28,6 +31,12 @@ Failure policy:
- Unsupported doc_type (no registered verifier) → keep the result and log a - Unsupported doc_type (no registered verifier) → keep the result and log a
warning. Verification is opt-in per type; a missing verifier is a soft warning. Verification is opt-in per type; a missing verifier is a soft
failure, not a search failure. failure, not a search failure.
The ``file`` verifier is the exception to the first rule: it gates on current
``vector-index`` tag membership (a single batch tag REPORT), so a file is
dropped+evicted when it is absent from the tag set — untagged, deleted, or
under an ``EXCLUDED_TAGS`` folder — not on a per-file 403/404. A failed tag
fetch still fails open. See ``_verify_files`` for the full contract.
""" """
import logging import logging
@@ -132,81 +141,110 @@ async def _verify_files(
results: list[SearchResult], results: list[SearchResult],
semaphore: anyio.Semaphore, semaphore: anyio.Semaphore,
) -> set[str]: ) -> set[str]:
"""Return the doc_ids of file results this user may actually access. """Return the doc_ids of file results this user may currently see.
Verifies each file by its *global* Nextcloud file id via an ACL-aware A file is included iff it *currently* carries the ``vector-index`` tag (the
WebDAV SEARCH (``webdav.file_accessible_by_id``), NOT by path. This is the same tag the scanner indexes on) AND is not under an ``EXCLUDED_TAGS``
ACL-aware-search fix: a file an owner shared with the querying user mounts folder — full parity with the indexing rules. The tag REPORT runs over the
at a different path under each tree, so the previous path-based check querying user's own files tree (including mounted shares), so membership in
(``get_file_info``) produced false 404s and dropped legitimate shared-file the tagged set already implies the file is *accessible*; this single batch
hits. Definitive 403/404 → inaccessible (dropped + scheduled for eviction fetch therefore subsumes the old per-file ``file_accessible_by_id`` check
by the caller); transient/ambiguous errors → kept (fail-open). and replaces N round-trips with one.
This is the verify-on-read fix for stale tags (ADR-019): a file removed
from the ``vector-index`` tag — or outright deleted — drops out of the
tagged set, so it is dropped from results and scheduled for eviction by the
caller immediately, rather than lingering until the scanner's grace-period
sweep reconciles it.
Failure policy mirrors ``_verify_news_items``: if the tag fetch itself
fails we keep every file result (fail-open, the next query re-verifies),
and malformed/non-numeric doc_ids are kept (defense-in-depth — the numeric
tag REPORT cannot match them, and producer-side validation is the real
boundary, so false-positive is preferred over false-negative).
""" """
# safe: cooperative concurrency, no lock needed (see verify_search_results) # Lazy import to break an import cycle: ``server/__init__`` imports
accessible: set[str] = set() # ``server.semantic`` which imports this module, so importing
# ``server.tag_exclusion`` at module load time would re-enter a
# partially-initialised ``server`` package depending on import order.
from nextcloud_mcp_server.server.tag_exclusion import ( # noqa: PLC0415
get_excluded_file_paths,
is_path_excluded,
)
async def check(result: SearchResult) -> None: tag_name = get_settings().vector_sync_pdf_tag
doc_id = result.id
# file_path is propagated from the Qdrant payload by the algorithm
# layer (bm25_hybrid.py / semantic.py); kept here only for log context.
file_path = (result.metadata or {}).get("path")
# Verify by *global* file ID via an ACL-aware WebDAV SEARCH, NOT by # One batch fetch per search, holding a single semaphore slot (same
# path. For files the vector ``doc_id`` IS the Nextcloud file ID, and # backpressure rationale as _verify_news_items): the tagged-file REPORT
# file_accessible_by_id searches the user's whole tree (incl. mounted # plus optional Depth:infinity folder expansion — and the EXCLUDED_TAGS
# shares), so a file an owner shared with this user verifies as # lookup — are one round-trip set, not one per result.
# accessible even though it lives at a different path under the owner's #
# root. A path-based check (the old behaviour) would 404 on shared # TODO(perf): if folder expansion dominates query latency, cache the
# files mounted at the recipient's root by basename and silently drop # tagged-id set per user with a short TTL (mirroring the
# legitimate ACL-aware-search results. # list_accessible_owners cache in search/access_filter.py). Skipped here so
# # an untag is reflected on the very next search rather than after a TTL.
# Hoisted cast mirrors _verify_notes: a malformed id keeps the result async with semaphore:
# (fail open) with a specific log line rather than a generic
# "unexpected error" from the catch-all below.
try: try:
file_id_int = int(doc_id) tagged = await client.find_files_by_tag(
except (TypeError, ValueError) as e: tag_name, mime_type_filter="application/pdf"
)
except HTTPStatusError as e:
logger.warning( logger.warning(
"Non-numeric file id %r (%s): %s; keeping result", "Transient error fetching %r-tagged files for verification: "
doc_id, "%s %s; keeping all file results",
file_path, tag_name,
e.response.status_code,
e, e,
) )
return {r.id for r in results}
except Exception as e:
logger.warning(
"Unexpected error fetching %r-tagged files for verification: "
"%s; keeping all file results",
tag_name,
e,
)
return {r.id for r in results}
# Exclusion wins: a tagged file under an EXCLUDED_TAGS folder must not
# surface, matching the scanner's defense-in-depth filter. A failure
# here degrades to "no exclusion" rather than dropping legitimate hits.
try:
excluded_paths = await get_excluded_file_paths(client.webdav)
except Exception as e:
logger.warning(
"EXCLUDED_TAGS lookup failed during verification (%s); "
"proceeding without exclusion filter",
e,
)
excluded_paths = set()
tagged_ids: set[str] = set()
for f in tagged:
file_id = f.get("id")
if file_id is None:
continue
if excluded_paths and is_path_excluded(f.get("path", ""), excluded_paths):
continue
# Normalise to str — Qdrant doc_id payload is keyword-indexed and the
# scanner stringifies file ids on write, so SearchResult.id is a str.
tagged_ids.add(str(file_id))
accessible: set[str] = set()
for r in results:
doc_id = r.id
if doc_id in tagged_ids:
accessible.add(doc_id) accessible.add(doc_id)
return elif not is_valid_nextcloud_doc_id(doc_id):
logger.warning(
async with semaphore: "Malformed file doc_id %r in verifier; keeping to avoid "
try: "dropping a potentially legitimate result (cannot match "
if await client.webdav.file_accessible_by_id(file_id_int): "against the numeric tag REPORT)",
accessible.add(doc_id) doc_id,
# else: definitively inaccessible (not owned, not shared) — )
# drop and let the caller schedule eviction. accessible.add(doc_id)
except HTTPStatusError as e: # else: a valid file id absent from the tagged set is untagged/deleted/
if _is_definitive_404_or_403(e): # excluded — drop it and let the caller schedule eviction.
return
logger.warning(
"Transient error verifying file %s (%s): %s %s; keeping result",
doc_id,
file_path,
e.response.status_code,
e,
)
accessible.add(doc_id)
except Exception as e:
# Network blip / unexpected WebDAV error — ambiguous, not a
# definitive denial. Keep the result; the next query re-verifies.
logger.warning(
"Unexpected error verifying file %s (%s): %s; keeping result",
doc_id,
file_path,
e,
)
accessible.add(doc_id)
async with anyio.create_task_group() as tg:
for r in results:
tg.start_soon(check, r)
return accessible return accessible
+1 -2
View File
@@ -4,7 +4,6 @@ Periodically scans enabled users' content and queues changed documents for proce
""" """
import logging import logging
import os
import random import random
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -396,7 +395,7 @@ async def scan_user_documents(
# PDF descendants (Depth: infinity SEARCH), so a tag on a # PDF descendants (Depth: infinity SEARCH), so a tag on a
# folder applies to every PDF beneath it. # folder applies to every PDF beneath it.
settings = get_settings() settings = get_settings()
tag_name = os.getenv("VECTOR_SYNC_PDF_TAG", "vector-index") tag_name = settings.vector_sync_pdf_tag
tagged_files = await nc_client.find_files_by_tag( tagged_files = await nc_client.find_files_by_tag(
tag_name, mime_type_filter="application/pdf" tag_name, mime_type_filter="application/pdf"
) )
+37 -5
View File
@@ -54,6 +54,16 @@ def _reset_owners_cache():
_DOC_TEXT = "Confidential quarterly infrastructure budget and capacity plan" _DOC_TEXT = "Confidential quarterly infrastructure budget and capacity plan"
# Minimal valid PDF. verify-on-read gates file results on the vector-index tag
# via find_files_by_tag(..., mime_type_filter="application/pdf"), so the shared
# file must be a PDF (matching what the scanner actually indexes), not a .txt.
_PDF_BYTES = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF\n"
)
def _user_client(username: str, password: str) -> NextcloudClient: def _user_client(username: str, password: str) -> NextcloudClient:
@@ -81,7 +91,13 @@ async def acl_users(test_users_setup):
@pytest.fixture @pytest.fixture
async def shared_file(acl_users): async def shared_file(acl_users):
"""alice creates a nested file and shares it with bob (not diana). """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. Yields (file_id, owner_relative_path); cleans up the directory after.
""" """
@@ -89,18 +105,28 @@ async def shared_file(acl_users):
suffix = uuid.uuid4().hex[:8] suffix = uuid.uuid4().hex[:8]
test_dir = f"acl_e2e_{suffix}" test_dir = f"acl_e2e_{suffix}"
nested = f"{test_dir}/reports" nested = f"{test_dir}/reports"
path = f"{nested}/budget.txt" path = f"{nested}/budget.pdf"
await alice.webdav.create_directory(test_dir) await alice.webdav.create_directory(test_dir)
await alice.webdav.create_directory(nested) await alice.webdav.create_directory(nested)
await alice.webdav.write_file(path, _DOC_TEXT.encode(), "text/plain") await alice.webdav.write_file(path, _PDF_BYTES, "application/pdf")
file_id = (await alice.webdav.get_file_info(path))["id"] 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( await alice.sharing.create_share(
path=f"/{path}", share_with="bob", share_type=0, permissions=1 path=f"/{path}", share_with="bob", share_type=0, permissions=1
) )
try: try:
yield file_id, path yield file_id, path
finally: finally:
try:
await alice.webdav.remove_tag_from_file(file_id, tag["id"])
except Exception:
pass
await alice.webdav.delete_resource(test_dir) await alice.webdav.delete_resource(test_dir)
@@ -132,7 +158,7 @@ async def seeded_semantic(monkeypatch, shared_file):
"user_id": "alice", "user_id": "alice",
"is_placeholder": False, "is_placeholder": False,
"file_path": path, "file_path": path,
"title": "budget.txt", "title": "budget.pdf",
"excerpt": _DOC_TEXT, "excerpt": _DOC_TEXT,
"chunk_index": 0, "chunk_index": 0,
"total_chunks": 1, "total_chunks": 1,
@@ -180,7 +206,13 @@ async def _search_as(user_client, file_id_unused) -> list:
async def test_recipient_finds_shared_file_without_indexing(acl_users, seeded_semantic): 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 """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 accessible owners to include alice, the filter surfaces her point, and
real verification confirms his ACL access — all without bob indexing.""" 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 file_id = seeded_semantic
# Sanity: the live OCS lookup really does expand bob to include alice. # Sanity: the live OCS lookup really does expand bob to include alice.
owners = await list_accessible_owners(acl_users["bob"].sharing, "bob") owners = await list_accessible_owners(acl_users["bob"].sharing, "bob")
+49 -23
View File
@@ -5,16 +5,16 @@ instance — the verification path's whole purpose is to consult Nextcloud as
the source of truth, so unit-level mocks don't catch protocol or status-code the source of truth, so unit-level mocks don't catch protocol or status-code
mismatches between our verifier and the real API. mismatches between our verifier and the real API.
**Coverage**: only the ``note`` verifier is exercised against real Nextcloud **Coverage**: the ``note`` verifier and the ``file`` verifier (tag-membership
here. The ``file`` (WebDAV PROPFIND), ``deck_card`` (Deck app), and gate, see the shared-recipient tests below) are exercised against real
``news_item`` (News app) verifiers are unit-tested with mocked HTTP Nextcloud here. The ``deck_card`` (Deck app) and ``news_item`` (News app)
responses in ``tests/unit/search/test_verification.py``. Adding integration verifiers are unit-tested with mocked HTTP responses in
coverage for those types is tracked as a follow-up — it requires fixture ``tests/unit/search/test_verification.py``. Adding integration coverage for
data (tagged PDFs in user files, a Deck board with cards, a News feed) that those types is tracked as a follow-up — it requires fixture data (a Deck board
is non-trivial to seed from CI. The mocked unit tests are accurate for with cards, a News feed) that is non-trivial to seed from CI. The mocked unit
status-code semantics but won't catch payload-shape regressions in those tests are accurate for status-code semantics but won't catch payload-shape
Nextcloud apps; the trade-off is documented here so future readers know regressions in those Nextcloud apps; the trade-off is documented here so
which suite owns which verifier. future readers know which suite owns which verifier.
Qdrant is mocked out (``delete_document_points`` and the payload-resolution Qdrant is mocked out (``delete_document_points`` and the payload-resolution
helpers) so these tests don't require a running vector database. The unit helpers) so these tests don't require a running vector database. The unit
@@ -30,6 +30,7 @@ import pytest
from httpx import BasicAuth, HTTPStatusError from httpx import BasicAuth, HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.search import verification from nextcloud_mcp_server.search import verification
from nextcloud_mcp_server.search.algorithms import SearchResult from nextcloud_mcp_server.search.algorithms import SearchResult
from nextcloud_mcp_server.search.verification import verify_search_results from nextcloud_mcp_server.search.verification import verify_search_results
@@ -38,6 +39,17 @@ logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
# Minimal valid PDF — the file verifier gates on the vector-index tag via
# find_files_by_tag(..., mime_type_filter="application/pdf"), so file fixtures
# must be PDFs (matching what the scanner indexes), not .txt.
_PDF_BYTES = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF\n"
)
def _result_for_note(note_id: int) -> SearchResult: def _result_for_note(note_id: int) -> SearchResult:
return SearchResult( return SearchResult(
@@ -51,9 +63,10 @@ def _result_for_note(note_id: int) -> SearchResult:
def _result_for_file(file_id: int, path: str) -> SearchResult: def _result_for_file(file_id: int, path: str) -> SearchResult:
# Mirrors what the algorithm layer propagates: doc_id IS the global file id, # Mirrors what the algorithm layer propagates: doc_id IS the global file id,
# ``path`` is carried in metadata (owner-relative) for log context only. # stringified (SearchResult.id is always str), ``path`` is carried in
# metadata (owner-relative) for log context only.
return SearchResult( return SearchResult(
id=file_id, id=str(file_id),
doc_type="file", doc_type="file",
title=path.split("/")[-1], title=path.split("/")[-1],
excerpt="...", excerpt="...",
@@ -222,11 +235,13 @@ async def alice_bob_clients(test_users_setup):
async def test_verify_keeps_nested_file_shared_with_recipient( async def test_verify_keeps_nested_file_shared_with_recipient(
alice_bob_clients, mocker alice_bob_clients, mocker
): ):
"""The PR #813 acceptance check at the verifier layer. """The PR #813 acceptance check at the verifier layer, under tag-gating.
Alice owns a file in a *subfolder* and shares it with Bob. Verifying the Alice owns a PDF in a *subfolder*, tags it ``vector-index`` (userVisible),
result as Bob must KEEP it — proving the id-based check sees the share. and shares it with Bob. Verifying the result as Bob must KEEP it — proving
A path-based check (the old behaviour) would 404 here and wrongly drop it. the tag REPORT surfaces an owner-assigned tag on a file shared into Bob's
tree. If a future Nextcloud version stops surfacing the owner's tag to a
recipient, this is where strict tag-gating regresses shared search.
""" """
spy_evict = mocker.AsyncMock() spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict) mocker.patch.object(verification, "delete_document_points", spy_evict)
@@ -235,12 +250,18 @@ async def test_verify_keeps_nested_file_shared_with_recipient(
suffix = uuid.uuid4().hex[:8] suffix = uuid.uuid4().hex[:8]
test_dir = f"acl_verify_{suffix}" test_dir = f"acl_verify_{suffix}"
nested_dir = f"{test_dir}/reports" nested_dir = f"{test_dir}/reports"
shared_path = f"{nested_dir}/shared.txt" shared_path = f"{nested_dir}/shared.pdf"
await alice.webdav.create_directory(test_dir) await alice.webdav.create_directory(test_dir)
await alice.webdav.create_directory(nested_dir) await alice.webdav.create_directory(nested_dir)
await alice.webdav.write_file(shared_path, b"alice's shared report", "text/plain") await alice.webdav.write_file(shared_path, _PDF_BYTES, "application/pdf")
file_id = (await alice.webdav.get_file_info(shared_path))["id"] file_id = (await alice.webdav.get_file_info(shared_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( await alice.sharing.create_share(
path=f"/{shared_path}", share_with="bob", share_type=0, permissions=1 path=f"/{shared_path}", share_with="bob", share_type=0, permissions=1
@@ -251,18 +272,23 @@ async def test_verify_keeps_nested_file_shared_with_recipient(
bob, [_result_for_file(file_id, shared_path)] bob, [_result_for_file(file_id, shared_path)]
) )
assert [r.id for r in kept] == [file_id], ( assert [r.id for r in kept] == [str(file_id)], (
"a nested file shared with bob must pass verification for bob" "a nested tagged PDF shared with bob must pass verification for bob"
) )
assert dropped_count == 0 assert dropped_count == 0
spy_evict.assert_not_awaited() spy_evict.assert_not_awaited()
finally: finally:
try:
await alice.webdav.remove_tag_from_file(file_id, tag["id"])
except Exception:
pass
await alice.webdav.delete_resource(test_dir) await alice.webdav.delete_resource(test_dir)
async def test_verify_drops_unshared_file_for_other_user(alice_bob_clients, mocker): 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 """Negative control: a file Alice did NOT share is absent from Bob's
must be dropped + scheduled for eviction under his identity.""" vector-index tag set (and his tree), so it must be dropped + scheduled for
eviction under his identity."""
spy_evict = mocker.AsyncMock() spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict) mocker.patch.object(verification, "delete_document_points", spy_evict)
@@ -282,6 +308,6 @@ async def test_verify_drops_unshared_file_for_other_user(alice_bob_clients, mock
assert kept == [], "an unshared file must not pass verification for bob" assert kept == [], "an unshared file must not pass verification for bob"
assert dropped_count == 1 assert dropped_count == 1
spy_evict.assert_awaited_once_with(file_id, "file", bob.username) spy_evict.assert_awaited_once_with(str(file_id), "file", bob.username)
finally: finally:
await alice.webdav.delete_resource(test_dir) await alice.webdav.delete_resource(test_dir)
+104 -110
View File
@@ -437,141 +437,135 @@ async def test_verify_news_items_malformed_api_response_keeps_all(mocker):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _patch_excluded(mocker, paths: set[str] | None = None, *, side_effect=None):
"""Patch the lazily-imported EXCLUDED_TAGS lookup used by _verify_files."""
if side_effect is not None:
mock = mocker.AsyncMock(side_effect=side_effect)
else:
mock = mocker.AsyncMock(return_value=paths if paths is not None else set())
return mocker.patch(
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_file_paths", mock
)
def _file_client(mocker, *, tagged=None, find_side_effect=None, username="alice"):
"""Build a client whose find_files_by_tag returns the given tagged files."""
if find_side_effect is not None:
find = mocker.AsyncMock(side_effect=find_side_effect)
else:
find = mocker.AsyncMock(return_value=tagged if tagged is not None else [])
return SimpleNamespace(
find_files_by_tag=find,
webdav=SimpleNamespace(),
username=username,
)
@pytest.mark.unit @pytest.mark.unit
async def test_verify_files_accessible_by_global_id_is_kept(mocker): async def test_verify_files_tagged_is_kept(mocker):
"""File verifier resolves the file by its global ID (the doc_id), ACL-aware. """A file currently carrying the vector-index tag is kept, and the tagged
set is fetched with a single batch call (not one per result)."""
_patch_excluded(mocker)
client = _file_client(mocker, tagged=[{"id": 100, "path": "/Documents/foo.pdf"}])
This is what lets a recipient verify a file an owner shared with them: result = await _verify_files(client, [_make_result(100, doc_type="file")], _sem())
file_accessible_by_id searches the user's whole tree (incl. mounted
shares) by global file id, not a path under the caller's own root (which
would 404 on shared files mounted at a different path).
"""
webdav_client = SimpleNamespace(
file_accessible_by_id=mocker.AsyncMock(return_value=True)
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(100, doc_type="file", metadata={"path": "Documents/foo.txt"})],
_sem(),
)
assert result == {"100"} assert result == {"100"}
webdav_client.file_accessible_by_id.assert_awaited_once_with(100) client.find_files_by_tag.assert_awaited_once_with(
"vector-index", mime_type_filter="application/pdf"
)
@pytest.mark.unit @pytest.mark.unit
async def test_verify_files_inaccessible_id_drops(mocker): async def test_verify_files_untagged_drops(mocker):
"""file_accessible_by_id returning False (file not in the user's tree) is a """A file removed from the vector-index tag (absent from the tagged set) is
definitive drop — the file is neither owned by nor shared with the user.""" dropped even though it may still exist and be readable by the user."""
webdav_client = SimpleNamespace( _patch_excluded(mocker)
file_accessible_by_id=mocker.AsyncMock(return_value=False) client = _file_client(mocker, tagged=[{"id": 100, "path": "/Documents/foo.pdf"}])
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files( result = await _verify_files(
client, client,
[_make_result(123, doc_type="file", metadata={"path": "gone.txt"})], [_make_result(100, doc_type="file"), _make_result(200, doc_type="file")],
_sem(), _sem(),
) )
# 100 is still tagged → kept; 200 was untagged → dropped.
assert result == {"100"}
@pytest.mark.unit
async def test_verify_files_deleted_drops(mocker):
"""A deleted file is absent from the tagged set → dropped (caller evicts)."""
_patch_excluded(mocker)
client = _file_client(mocker, tagged=[])
result = await _verify_files(client, [_make_result(123, doc_type="file")], _sem())
assert result == set() assert result == set()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_files_403_404_drops(mocker): async def test_verify_files_excluded_path_drops(mocker):
"""A 403/404 raised by the SEARCH call is treated as a definitive drop, """A tagged file under an EXCLUDED_TAGS folder must not surface — exclusion
consistent with the shared _is_definitive_404_or_403 policy used by every wins, parity with the scanner's defense-in-depth filter."""
verifier. (Normal inaccessibility surfaces as an empty result set, not a # get_excluded_file_paths returns slash-stripped (normalised) paths.
status code, and is covered by test_verify_files_inaccessible_id_drops.)""" _patch_excluded(mocker, {"Secret"})
for status in (403, 404): client = _file_client(
webdav_client = SimpleNamespace( mocker,
file_accessible_by_id=mocker.AsyncMock(side_effect=_http_error(status)) tagged=[
) {"id": 100, "path": "/Documents/foo.pdf"},
client = SimpleNamespace(webdav=webdav_client, username="alice") {"id": 200, "path": "/Secret/bar.pdf"},
],
)
result = await _verify_files(
client,
[_make_result(100, doc_type="file"), _make_result(200, doc_type="file")],
_sem(),
)
assert result == {"100"}
@pytest.mark.unit
async def test_verify_files_tag_fetch_failure_keeps_all(mocker):
"""If the tag REPORT itself fails (HTTP or otherwise), keep every file
result (fail-open) — never silently shrink results on a backend blip."""
_patch_excluded(mocker)
for exc in (_http_error(503), _http_error(429), RuntimeError("dav blew up")):
client = _file_client(mocker, find_side_effect=exc)
result = await _verify_files( result = await _verify_files(
client, client,
[_make_result(124, doc_type="file", metadata={"path": "x.txt"})], [_make_result(7, doc_type="file"), _make_result(8, doc_type="file")],
_sem(), _sem(),
) )
assert result == {"7", "8"}, f"{exc!r} on the tag fetch must keep all results"
assert result == set(), f"{status} on the SEARCH call must drop"
@pytest.mark.unit @pytest.mark.unit
async def test_verify_files_non_numeric_id_keeps_unverified(mocker): async def test_verify_files_exclusion_lookup_failure_proceeds(mocker):
"""Without a numeric file id we cannot verify — fail open, don't drop.""" """If the EXCLUDED_TAGS lookup fails, proceed without the exclusion filter
webdav_client = SimpleNamespace( rather than dropping legitimate tagged hits."""
file_accessible_by_id=mocker.AsyncMock( _patch_excluded(mocker, side_effect=RuntimeError("ocs down"))
side_effect=AssertionError("must not be called") client = _file_client(mocker, tagged=[{"id": 100, "path": "/Documents/foo.pdf"}])
)
) result = await _verify_files(client, [_make_result(100, doc_type="file")], _sem())
client = SimpleNamespace(webdav=webdav_client, username="alice")
assert result == {"100"}
@pytest.mark.unit
async def test_verify_files_non_numeric_id_keeps(mocker):
"""A malformed (non-numeric) doc_id cannot be matched against the numeric
tag REPORT, so it is kept (defense-in-depth, false-positive preferred)."""
_patch_excluded(mocker)
client = _file_client(mocker, tagged=[])
result = await _verify_files( result = await _verify_files(
client, client, [_make_result("not-a-file-id", doc_type="file")], _sem()
[_make_result("not-a-file-id", doc_type="file", metadata={"path": "x.txt"})],
_sem(),
) )
assert result == {"not-a-file-id"} assert result == {"not-a-file-id"}
webdav_client.file_accessible_by_id.assert_not_awaited()
@pytest.mark.unit
async def test_verify_files_transient_5xx_keeps(mocker):
webdav_client = SimpleNamespace(
file_accessible_by_id=mocker.AsyncMock(side_effect=_http_error(503))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(7, doc_type="file", metadata={"path": "x.txt"})],
_sem(),
)
assert result == {"7"}
@pytest.mark.unit
async def test_verify_files_429_keeps_as_transient(mocker):
"""HTTP 429 from the SEARCH call must NOT silently drop file results."""
webdav_client = SimpleNamespace(
file_accessible_by_id=mocker.AsyncMock(side_effect=_http_error(429))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(7, doc_type="file", metadata={"path": "x.txt"})],
_sem(),
)
assert result == {"7"}
@pytest.mark.unit
async def test_verify_files_unexpected_exception_keeps(mocker):
"""A non-HTTP exception from file_accessible_by_id must not drop the result.
The catch-all ``except Exception`` branch in the file verifier exists
so a bug in the WebDAV client (or an httpx ConnectError on a flaky
network) cannot silently shrink result pages.
"""
webdav_client = SimpleNamespace(
file_accessible_by_id=mocker.AsyncMock(side_effect=RuntimeError("dav blew up"))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(8, doc_type="file", metadata={"path": "y.txt"})],
_sem(),
)
assert result == {"8"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -889,11 +883,11 @@ async def test_verify_evicts_cross_user_file_under_querying_user_id(mocker):
""" """
spy_evict = mocker.AsyncMock() spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict) mocker.patch.object(verification, "delete_document_points", spy_evict)
_patch_excluded(mocker)
webdav_client = SimpleNamespace( # The shared file is no longer in bob's tagged set (share revoked → absent
file_accessible_by_id=mocker.AsyncMock(return_value=False) # from his vector-index tag REPORT), so the file verifier drops it.
) client = _file_client(mocker, tagged=[], username="bob")
client = SimpleNamespace(webdav=webdav_client, username="bob")
kept, dropped_count = await verify_search_results( kept, dropped_count = await verify_search_results(
client, client,