Merge pull request #911 from cbcoutinho/feat/admin-searchable-sources

feat(vector-sync): honor Astrolabe admin consent for searchable sources
This commit is contained in:
Chris Coutinho
2026-06-16 16:26:35 +02:00
committed by GitHub
14 changed files with 1407 additions and 7 deletions
+5
View File
@@ -36,6 +36,9 @@ from nextcloud_mcp_server.api.passwords import (
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.api.vector_sync import (
purge_doc_types_route,
)
from nextcloud_mcp_server.api.visualization import (
get_chunk_context,
get_pdf_preview,
@@ -78,6 +81,8 @@ __all__ = [
"list_webhooks",
"create_webhook",
"delete_webhook",
# Vector-sync admin endpoints (from vector_sync.py)
"purge_doc_types_route",
# Visualization endpoints (from visualization.py)
"unified_search",
"vector_search",
+152
View File
@@ -0,0 +1,152 @@
"""Vector-sync admin API endpoints.
Provides the purge endpoint Astrolabe calls when an admin disables a content
source for semantic search. Consent is binding on data-at-rest, so the
already-indexed content for the disabled source's doc type(s) is deleted
globally (every owner) — see :mod:`nextcloud_mcp_server.vector.purge`.
Auth: the OAuth bearer identifies the caller (``validate_token_and_get_user``);
because the purge deletes every owner's content for a doc type, it is further
restricted to Nextcloud administrators (verified via the ``admin`` group using
the caller's app password). This is stricter than the per-user webhook routes
in :mod:`nextcloud_mcp_server.api.webhooks` precisely because the blast radius
is global.
"""
import logging
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api._auth import get_basic_auth_for_user
from nextcloud_mcp_server.api.management import (
_sanitize_error_for_client,
validate_token_and_get_user,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.client.users import UsersClient
from nextcloud_mcp_server.vector.purge import purge_doc_types
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# Upper bound on doc_types per purge request. There are only a handful of real
# indexed types; this caps a hostile/buggy caller's fan-out of count+delete
# calls without constraining legitimate use.
_MAX_PURGE_DOC_TYPES = 64
def _bad_request(message: str) -> JSONResponse:
return JSONResponse({"error": "Bad request", "message": message}, status_code=400)
async def purge_doc_types_route(request: Request) -> JSONResponse:
"""POST /api/v1/vector-sync/purge — delete indexed vectors by doc type.
Request body::
{"doc_types": ["file", "note"]}
Returns ``{"purged": {doc_type: deleted_count}}``. Admin-only.
Requires OAuth bearer token for authentication.
"""
try:
user_id, _ = await validate_token_and_get_user(request)
except Exception as e:
logger.warning("Unauthorized access to /api/v1/vector-sync/purge: %s", e)
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "purge_doc_types"),
},
status_code=401,
)
try:
body = await request.json()
except Exception as e:
logger.warning("Purge payload was not valid JSON: %s", e)
return _bad_request("invalid JSON")
if not isinstance(body, dict):
return _bad_request("body must be a JSON object")
raw = body.get("doc_types")
if raw is None:
return _bad_request("doc_types is required")
if not isinstance(raw, list) or not all(isinstance(d, str) for d in raw):
return _bad_request("doc_types must be a list of strings")
doc_types = [d for d in raw if d]
# No whitelist against INDEXED_DOC_TYPES on purpose: an unknown type yields a
# zero-match Qdrant filter (harmless no-op), and the canonical set lives with
# the indexer — the route shouldn't need a server update to purge a new type.
# Bound the batch: there are only a handful of real indexed types, so a huge
# list is abuse — cap it rather than fan out unbounded count+delete calls.
if len(doc_types) > _MAX_PURGE_DOC_TYPES:
return _bad_request(f"doc_types exceeds the maximum of {_MAX_PURGE_DOC_TYPES}")
try:
username, app_password = await get_basic_auth_for_user(user_id)
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Verify admin via the caller's own app password before any deletion —
# enforced even for an empty (no-op) request, since this is a
# destructive admin route.
async with nextcloud_httpx_client(
base_url=nextcloud_host,
auth=httpx.BasicAuth(username, app_password),
timeout=30.0,
) as client:
users_client = UsersClient(client, username)
user_groups = await users_client.get_user_groups(username)
if "admin" not in user_groups:
logger.warning("Non-admin user %s attempted vector-sync purge", user_id)
return JSONResponse(
{
"error": "Forbidden",
"message": "Administrator privileges required",
},
status_code=403,
)
if not doc_types:
return JSONResponse({"purged": {}})
purged = await purge_doc_types(doc_types)
# Surface a partial-failure signal so Astrolabe knows which types were
# NOT purged (consent not yet enforced for them) — the scanner backstop
# still catches these, but the caller shouldn't assume full success.
failed = [dt for dt in dict.fromkeys(doc_types) if dt not in purged]
resp: dict = {"purged": purged}
if failed:
resp["failed"] = failed
logger.info(
"Vector-sync purge by admin %s: purged=%s failed=%s",
user_id,
purged,
failed,
)
return JSONResponse(resp)
except ProvisioningRequiredError as e:
logger.info("Provisioning required for user %s: %s", user_id, e)
return JSONResponse(
{"error": "Provisioning required", "message": str(e)},
status_code=428,
)
except Exception as e:
logger.exception("Error purging doc types for user %s", user_id)
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "purge_doc_types"),
},
status_code=500,
)
+14
View File
@@ -47,6 +47,7 @@ from nextcloud_mcp_server.api import (
list_supported_scopes,
list_webhooks,
provision_app_password,
purge_doc_types_route,
revoke_user_access,
unified_search,
update_user_scopes,
@@ -2422,6 +2423,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
routes.append(
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
)
# Vector-sync admin: purge indexed vectors by doc type (admin consent —
# called by Astrolabe when a source is disabled for semantic search).
# Gated on vector_sync_enabled: without it there is no Qdrant client, so
# the purge would 500 rather than no-op.
if settings.vector_sync_enabled:
routes.append(
Route(
"/api/v1/vector-sync/purge",
purge_doc_types_route,
methods=["POST"],
)
)
logger.info("Vector-sync admin endpoint enabled: /api/v1/vector-sync/purge")
# Access and scope management endpoints (ADR-022)
routes.append(
Route(
+123
View File
@@ -0,0 +1,123 @@
"""Reads admin-approved searchable sources from the Astrolabe capability.
The Astrolabe Nextcloud app advertises, per user, which content sources an
admin has approved for semantic search, under
``capabilities.astrolabe.semantic_search.enabled_doc_types`` on the OCS
capabilities endpoint (``/ocs/v2.php/cloud/capabilities``). This is the single
source of truth for admin consent: the search layer filters results to these
doc types, and the indexing layer (scanner + webhook ingest) skips everything
else (the hard data-at-rest guarantee is the eager purge Astrolabe triggers on
disable; see ``vector.purge``).
Fail-open for *availability*: if the capability block is absent (an older
Astrolabe that predates this feature) or the OCS call fails, ``allowed_doc_types``
returns ``None`` meaning "no restriction", so search keeps working. ``None`` is
distinct from an empty set, which means "the admin disabled every source".
"""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from typing import Any, Protocol
logger = logging.getLogger(__name__)
# Short-lived per-user cache for the OCS capabilities lookup. Admin consent
# changes rarely, but search/scan paths consult it frequently, so trade a little
# staleness for keeping the OCS round-trip off the hot path. Mirrors the
# list_accessible_owners cache in search/access_filter.py.
#
# Keyed by user_id even though enabled_doc_types is an admin-wide value: the OCS
# call is authenticated per-user (and ``installed`` resolves per-user on the
# Astrolabe side), so we cache per-user for correctness. The redundancy is
# bounded by _CACHE_MAXSIZE; on an admin change all entries reconverge within
# one TTL window.
_CACHE_TTL_SECONDS = 30.0
_CACHE_MAXSIZE = 1024
# user_id -> (monotonic_ts, frozenset[doc_type] | None). None = no restriction.
_cache: OrderedDict[str, tuple[float, frozenset[str] | None]] = OrderedDict()
class _CapabilitiesClientProtocol(Protocol):
async def capabilities(self) -> Any: ...
def _parse_enabled_doc_types(payload: Any) -> frozenset[str] | None:
"""Extract ``enabled_doc_types`` from an OCS capabilities payload.
Returns ``None`` when the ``astrolabe.semantic_search`` block is absent or
malformed (treated as "no restriction"). Returns a frozenset (possibly
empty) when the block is present and well-formed; an empty set means the
admin disabled every source.
"""
if not isinstance(payload, dict):
return None
try:
caps = payload["ocs"]["data"]["capabilities"]
except (KeyError, TypeError):
return None
if not isinstance(caps, dict):
return None
block = caps.get("astrolabe")
if not isinstance(block, dict):
return None
semantic = block.get("semantic_search")
if not isinstance(semantic, dict):
return None
raw = semantic.get("enabled_doc_types")
if not isinstance(raw, list):
return None
return frozenset(dt for dt in raw if isinstance(dt, str) and dt)
async def allowed_doc_types(
client: _CapabilitiesClientProtocol, user_id: str
) -> frozenset[str] | None:
"""Admin-approved doc types for ``user_id``, or ``None`` for "no restriction".
Cached per user with a short TTL (+ LRU eviction). Failures are not cached so
a transient OCS hiccup retries on the next call. Fail-open: a missing
capability block or an error yields ``None`` so search remains available.
"""
now = time.monotonic()
cached = _cache.get(user_id)
if cached is not None and now - cached[0] < _CACHE_TTL_SECONDS:
_cache.move_to_end(user_id) # mark recently used (LRU)
return cached[1]
try:
payload = await client.capabilities()
except Exception as exc: # noqa: BLE001 — degrade gracefully (fail-open)
logger.warning(
"Astrolabe capabilities unavailable for user %s (%s); "
"not restricting doc types this cycle",
user_id,
exc,
)
return None # don't cache failures — retry next call
result = _parse_enabled_doc_types(payload)
_cache[user_id] = (now, result)
# Needed only for an existing (expired) key: __setitem__ updates it in place,
# keeping its old position, so move it to the end to preserve LRU order. For
# a brand-new key __setitem__ already appends, so this is a harmless no-op.
_cache.move_to_end(user_id)
while len(_cache) > _CACHE_MAXSIZE:
_cache.popitem(last=False) # evict least-recently-used
return result
def is_doc_type_allowed(doc_type: str, allowed: frozenset[str] | None) -> bool:
"""Whether ``doc_type`` may be indexed/searched given an allow-set.
``allowed=None`` means "no restriction" (fail-open / older Astrolabe), so
everything is permitted.
"""
return allowed is None or doc_type in allowed
def clear_cache() -> None:
"""Test hook: drop all cached entries."""
_cache.clear()
+51
View File
@@ -20,6 +20,7 @@ from mcp.types import (
from pydantic import Field
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.capabilities import allowed_doc_types
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.semantic import (
@@ -56,6 +57,25 @@ logger = logging.getLogger(__name__)
_USAGE_METADATA_MAX_DOC_TYPES = 16
def _consent_narrowed_doc_types(
doc_types: list[str] | None, allowed: frozenset[str]
) -> list[str]:
"""Apply the admin allow-set to a requested ``doc_types`` filter.
Caller has already established ``allowed is not None`` (a concrete allow-set;
``None`` means "no restriction" and is handled by skipping this call). When
no explicit ``doc_types`` are requested, restrict to the full allow-set
(returned ``sorted`` for determinism only — order is a filter, not a ranking
hint); otherwise intersect (preserving the caller's order). An empty result
means nothing the caller asked for is admin-approved — the caller
short-circuits to an empty response rather than falling through to an
all-types search.
"""
if doc_types is None:
return sorted(allowed)
return [dt for dt in doc_types if dt in allowed]
async def record_search_usage(
*,
enabled: bool,
@@ -300,6 +320,37 @@ def configure_semantic_tools(mcp: FastMCP):
# files under their own user_id.
accessible_owners = await list_accessible_owners(client.sharing, username)
# Admin consent gate: restrict to source types the Astrolabe admin has
# approved (and that are installed for this user). This mirrors
# Astrolabe's own server-side enforcement but is independent because
# this tool queries Qdrant directly. ``None`` = no restriction
# (fail-open / Astrolabe predating this feature). An empty allow-set
# means the admin disabled every source.
#
# Perf trade-off (accepted): when Astrolabe is present and the caller
# passed no doc_types, narrowing turns ``None`` into a concrete list, so
# the search takes the per-type query branch (N queries) instead of the
# single cross-type query. N is the count of admin-approved types
# (typically 1-4), so the overhead is small; left as-is rather than
# adding a "search all approved in one query" fast path.
allowed = await allowed_doc_types(client, username)
if allowed is not None:
doc_types = _consent_narrowed_doc_types(doc_types, allowed)
if not doc_types:
logger.info(
"Semantic search short-circuited for user %s: no requested "
"doc_type is admin-approved for semantic search",
username,
)
return SemanticSearchResponse(
results=[],
query=query,
total_found=0,
search_method=f"bm25_hybrid_{fusion}",
verified_chunk_count=0,
dropped_document_count=0,
)
try:
# The nc_semantic_search tool deliberately uses BM25-hybrid (dense +
# sparse with RRF/DBSF fusion) as the single tool-layer algorithm.
+22
View File
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
from nextcloud_mcp_server.document_processors.registry import ProcessorRegistry
from nextcloud_mcp_server.acl_hash import compute_acl_hash
from nextcloud_mcp_server.capabilities import allowed_doc_types, is_doc_type_allowed
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
@@ -590,6 +591,27 @@ async def process_document(
):
await _reconcile_tag_event(doc_task, nc_client)
# Admin consent gate (Astrolabe): never index a source the admin has
# disabled for semantic search — this catches near-real-time webhook
# events that bypass the scanner's discovery gate. Deletes always
# proceed (removing data honours consent). ``None`` from the reader
# means no restriction (fail-open / older Astrolabe), so a transient
# capabilities failure never silently drops indexing.
if doc_task.operation == "index":
allowed = await allowed_doc_types(nc_client, doc_task.user_id)
if not is_doc_type_allowed(doc_task.doc_type, allowed):
logger.info(
"Skipping index of %s_%s for %s: doc_type disabled by admin",
doc_task.doc_type,
doc_task.doc_id,
doc_task.user_id,
)
# Alertable counter so a flood of webhook events for a
# disabled source is observable (not silently swallowed).
record_ingest_dropped("admin_disabled")
record_vector_sync_processing(time.time() - start_time, "skipped")
return
# Handle deletion
if doc_task.operation == "delete":
# Release this user rather than blind-delete: a file shared across
+77
View File
@@ -0,0 +1,77 @@
"""Global purge of indexed vectors by doc type (admin consent enforcement).
When an admin disables a content source for semantic search in Astrolabe,
consent is binding on data-at-rest: the already-indexed content for that
source's doc type(s) must be deleted, not merely hidden. Astrolabe calls the
``/api/v1/vector-sync/purge`` route on disable, which delegates here.
The purge is global (every owner) because the admin disable is a global
decision. It is safe to call for a doc type with no indexed points — Qdrant
deletes zero points and reports a count of 0.
"""
from __future__ import annotations
import logging
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
def _doc_type_filter(doc_type: str) -> Filter:
return Filter(
must=[FieldCondition(key="doc_type", match=MatchValue(value=doc_type))]
)
async def purge_doc_types(doc_types: list[str]) -> dict[str, int]:
"""Delete every indexed point whose ``doc_type`` is in ``doc_types``.
Returns a mapping of doc_type -> number of points deleted (counted before
deletion). Each doc type is purged independently so a failure on one does
not abort the rest; failures re-raise after the loop only if every doc type
failed, otherwise partial progress is returned.
The count is taken just before the delete (two separate Qdrant calls), so
it is approximate — a point indexed in the gap is deleted but not counted.
This is acceptable: indexing of a disabled source is already gated upstream,
so the window is effectively empty in practice.
"""
qdrant_client = await get_qdrant_client()
collection = get_settings().get_collection_name()
purged: dict[str, int] = {}
last_error: Exception | None = None
for doc_type in dict.fromkeys(doc_types): # de-dupe, preserve order
flt = _doc_type_filter(doc_type)
try:
count_result = await qdrant_client.count(
collection_name=collection,
count_filter=flt,
exact=True,
)
await qdrant_client.delete(
collection_name=collection,
points_selector=flt,
)
purged[doc_type] = int(count_result.count)
logger.info(
"Purged %d indexed point(s) for disabled doc_type=%s",
purged[doc_type],
doc_type,
)
except Exception as exc: # noqa: BLE001 — record and continue
last_error = exc
logger.exception(
"Failed to purge indexed points for doc_type=%s",
doc_type,
)
if not purged and last_error is not None:
# Nothing succeeded — surface the failure to the caller (HTTP 500).
raise last_error
return purged
+180 -3
View File
@@ -16,6 +16,7 @@ from httpx import HTTPStatusError
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import FieldCondition, Filter, MatchValue, Record
from nextcloud_mcp_server.capabilities import allowed_doc_types, is_doc_type_allowed
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.config import get_settings
@@ -279,6 +280,150 @@ def _app_enabled(app_id: str, enabled_apps: set[str] | None) -> bool:
return enabled_apps is None or app_id in enabled_apps
def _should_scan(
app_id: str,
doc_type: str,
enabled_apps: set[str] | None,
allowed: frozenset[str] | None,
) -> bool:
"""Whether to scan ``app_id``: installed for the user AND admin-approved."""
return _app_enabled(app_id, enabled_apps) and is_doc_type_allowed(doc_type, allowed)
# Text doc types whose deletion-tracking lives *inside* their scan_* function,
# so skipping that function (when admin-disabled) leaves indexed points with no
# grace-period backstop. Derived from INDEXED_DOC_TYPES so a newly-indexed type
# automatically gets the backstop. ``file`` is excluded: its scan path empties
# discovery and lets the existing reconcile loop purge on disable.
_TEXT_BACKSTOP_DOC_TYPES: tuple[str, ...] = tuple(sorted(INDEXED_DOC_TYPES - {"file"}))
# Per-process record of (user_id, doc_type) whose consent backstop deletes have
# already been enqueued, so a *standing* admin-disable doesn't re-flood the
# processor with idempotent deletes on every scan tick. An entry is cleared once
# the type is allowed again, so a later re-disable re-triggers the backstop.
# A dict (not a set) so it stays insertion-ordered for oldest-first eviction.
_consent_backstop_done: dict[tuple[str, str], None] = {}
# Safety bound on the tracking dict so a long-running multi-tenant process with
# heavy user churn (deprovisioned users leave stale entries) can't grow it
# without limit. At <= len(INDEXED_DOC_TYPES) entries per user this is generous;
# on overflow we evict the *oldest* entries down to half capacity (not a full
# clear) so the backstop re-fires for only those, avoiding a fleet-wide burst.
_CONSENT_BACKSTOP_MAX = 50_000
def _mark_backstop_done(key: tuple[str, str]) -> None:
"""Record a one-shot backstop marker, evicting oldest entries on overflow.
Evicts oldest-first down to half capacity (insertion-ordered dict) rather
than clearing wholesale, so a bound hit re-fires the backstop only for the
oldest markers, not the whole fleet. A re-fire is idempotent regardless.
"""
if len(_consent_backstop_done) >= _CONSENT_BACKSTOP_MAX:
overage = len(_consent_backstop_done) - _CONSENT_BACKSTOP_MAX // 2
logger.info(
"consent backstop tracking hit %d entries; evicting %d oldest",
_CONSENT_BACKSTOP_MAX,
overage,
)
for stale_key in list(_consent_backstop_done)[:overage]:
del _consent_backstop_done[stale_key]
_consent_backstop_done[key] = None
async def _backstop_delete_doc_type(
user_id: str,
send_stream: TaskProducer,
doc_type: str,
qdrant_client: AsyncQdrantClient,
collection: str,
scan_id: int,
) -> int:
"""Enqueue delete tasks for every indexed point of one disabled doc_type.
Returns the number of delete tasks enqueued.
"""
points = await _scroll_all_points(
qdrant_client,
collection_name=collection,
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
]
),
payload_fields=["doc_id"],
)
doc_ids = {
str(p.payload["doc_id"])
for p in points
if p.payload is not None and "doc_id" in p.payload
}
if doc_ids:
logger.info(
"[SCAN-%s] %s disabled by admin for %s; enqueueing %d delete(s) (backstop)",
scan_id,
doc_type,
user_id,
len(doc_ids),
)
for doc_id in doc_ids:
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type=doc_type,
operation="delete",
modified_at=0,
)
)
return len(doc_ids)
async def _enqueue_deletes_for_disabled_types(
user_id: str,
send_stream: TaskProducer,
allowed: frozenset[str] | None,
scan_id: int,
) -> int:
"""Enqueue delete tasks for indexed text-source points the admin disabled.
Backstop for a failed eager purge: scrolls this user's indexed points for
each admin-disallowed text doc_type and queues a delete. No-op when
``allowed`` is ``None`` (fail-open — never delete on a transient capability
read failure). Returns the number of delete tasks enqueued.
"""
if allowed is None:
return 0
# Re-enabled types: clear their one-shot marker so a later re-disable
# re-triggers the backstop.
for doc_type in _TEXT_BACKSTOP_DOC_TYPES:
if doc_type in allowed:
_consent_backstop_done.pop((user_id, doc_type), None)
# Disabled types not yet backstopped this episode.
disabled = [
dt
for dt in _TEXT_BACKSTOP_DOC_TYPES
if dt not in allowed and (user_id, dt) not in _consent_backstop_done
]
if not disabled:
return 0
qdrant_client = await get_qdrant_client()
collection = get_settings().get_collection_name()
queued = 0
for doc_type in disabled:
queued += await _backstop_delete_doc_type(
user_id, send_stream, doc_type, qdrant_client, collection, scan_id
)
# Mark backstopped (even when nothing was found) so subsequent scans
# don't re-scroll/re-enqueue for this disable episode.
_mark_backstop_done((user_id, doc_type))
return queued
async def scan_user_documents(
user_id: str,
send_stream: TaskProducer,
@@ -365,6 +510,13 @@ async def scan_user_documents(
# detection failed: fall back to scanning every app (prior behaviour).
enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id)
# Admin consent gate (Astrolabe): only index sources the admin has
# approved for semantic search. ``None`` = no restriction (fail-open /
# older Astrolabe), so a transient capabilities failure never silently
# halts (or worse, mass-deletes) indexing. This is independent of
# ``enabled_apps``, which reflects only what the user has installed.
allowed = await allowed_doc_types(nc_client, user_id)
# Notes (isolated so an uninstalled or disabled Notes app — whose API
# returns 404 — cannot abort scanning of the other apps; this mirrors the
# per-app try/except guards already wrapping files/news/deck below).
@@ -373,7 +525,17 @@ async def scan_user_documents(
current_time = time.time()
queued = 0
if _app_enabled("notes", enabled_apps):
# Backstop purge for admin-disabled text sources. Their deletion-
# tracking lives inside the scan_* function we skip below, so (unlike
# files, whose discovery-empties-then-reconcile path purges on disable)
# they'd linger if Astrolabe's eager purge failed. Enqueue deletes for
# any indexed points of a now-disallowed type. Gated on a concrete
# allow-set, so a fail-open None never triggers deletion.
queued += await _enqueue_deletes_for_disabled_types(
user_id, send_stream, allowed, scan_id
)
if _should_scan("notes", "note", enabled_apps, allowed):
try:
queued += await scan_notes(
user_id=user_id,
@@ -454,9 +616,24 @@ async def scan_user_documents(
# folder applies to every PDF beneath it.
settings = get_settings()
tag_name = settings.vector_sync_pdf_tag
if is_doc_type_allowed("file", allowed):
tagged_files = await nc_client.find_files_by_tag(
tag_name, mime_type_filter="application/pdf"
)
else:
# Files disabled by admin: discover nothing so no new file is
# indexed. The deletion-reconcile below then sees every indexed
# file as "missing" and purges it after the grace period — the
# backstop for the eager purge Astrolabe runs on disable.
# Asymmetry (intentional): files purge up to 1.5x scan_interval
# later than text types, which get immediate one-shot backstop
# deletes via _enqueue_deletes_for_disabled_types.
logger.debug(
"[SCAN-%s] Files disabled by admin for %s; skipping tagged-file discovery",
scan_id,
user_id,
)
tagged_files = []
# Apply EXCLUDED_TAGS as defense-in-depth: a folder marked
# off-limits via the exclusion tag must not be indexed even if
@@ -710,7 +887,7 @@ async def scan_user_documents(
# Scan News items (starred + unread)
news_queued = 0
if _app_enabled("news", enabled_apps):
if _should_scan("news", "news_item", enabled_apps, allowed):
try:
news_queued = await scan_news_items(
user_id=user_id,
@@ -731,7 +908,7 @@ async def scan_user_documents(
# Scan Deck cards
deck_queued = 0
if _app_enabled("deck", enabled_apps):
if _should_scan("deck", "deck_card", enabled_apps, allowed):
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
@@ -0,0 +1,41 @@
"""Unit tests for the search-side consent narrowing in nc_semantic_search.
The narrowing logic (intersect requested doc_types with the admin allow-set,
or restrict to the allow-set when none requested) is extracted into
``_consent_narrowed_doc_types`` so it can be tested without exercising the full
search path. ``allowed is None`` (no restriction / fail-open) is handled by the
caller skipping this helper entirely.
"""
from __future__ import annotations
import pytest
from nextcloud_mcp_server.server.semantic import _consent_narrowed_doc_types
pytestmark = pytest.mark.unit
def test_none_request_restricts_to_allow_set():
# No explicit doc_types -> search exactly the allowed set (sorted).
assert _consent_narrowed_doc_types(None, frozenset({"file", "note"})) == [
"file",
"note",
]
def test_request_intersected_with_allow_set_preserves_order():
result = _consent_narrowed_doc_types(
["deck_card", "note", "file"], frozenset({"note", "file"})
)
assert result == ["note", "file"]
def test_disjoint_request_yields_empty():
# Caller short-circuits to an empty response on [].
assert _consent_narrowed_doc_types(["deck_card"], frozenset({"note"})) == []
def test_empty_allow_set_blocks_all():
assert _consent_narrowed_doc_types(None, frozenset()) == []
assert _consent_narrowed_doc_types(["note"], frozenset()) == []
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for the Astrolabe searchable-sources capability reader."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
import nextcloud_mcp_server.capabilities as cap
from nextcloud_mcp_server.capabilities import (
_parse_enabled_doc_types,
allowed_doc_types,
clear_cache,
is_doc_type_allowed,
)
pytestmark = pytest.mark.unit
def _payload(enabled_doc_types) -> dict:
"""Build an OCS capabilities envelope carrying the astrolabe block.
``enabled_doc_types=...`` (Ellipsis) omits the key entirely.
"""
semantic: dict = {}
if enabled_doc_types is not ...:
semantic["enabled_doc_types"] = enabled_doc_types
return {
"ocs": {
"meta": {"status": "ok"},
"data": {"capabilities": {"astrolabe": {"semantic_search": semantic}}},
}
}
# ---------------------------------------------------------------------------
# _parse_enabled_doc_types
# ---------------------------------------------------------------------------
def test_parse_present_list_returns_set():
assert _parse_enabled_doc_types(_payload(["note", "file"])) == {"note", "file"}
def test_parse_empty_list_returns_empty_set():
# Admin disabled every source — distinct from "no restriction".
assert _parse_enabled_doc_types(_payload([])) == set()
def test_parse_missing_astrolabe_block_returns_none():
payload = {"ocs": {"data": {"capabilities": {}}}}
assert _parse_enabled_doc_types(payload) is None
def test_parse_missing_enabled_key_returns_none():
assert _parse_enabled_doc_types(_payload(...)) is None
def test_parse_malformed_payload_returns_none():
assert _parse_enabled_doc_types(None) is None
assert _parse_enabled_doc_types({"ocs": "nope"}) is None
assert _parse_enabled_doc_types(_payload("not-a-list")) is None
def test_parse_drops_non_string_entries():
assert _parse_enabled_doc_types(_payload(["note", 5, None])) == {"note"}
# ---------------------------------------------------------------------------
# is_doc_type_allowed
# ---------------------------------------------------------------------------
def test_is_doc_type_allowed_none_means_no_restriction():
assert is_doc_type_allowed("anything", None) is True
def test_is_doc_type_allowed_respects_set():
allowed = frozenset({"note"})
assert is_doc_type_allowed("note", allowed) is True
assert is_doc_type_allowed("file", allowed) is False
def test_is_doc_type_allowed_empty_set_blocks_all():
assert is_doc_type_allowed("note", frozenset()) is False
# ---------------------------------------------------------------------------
# allowed_doc_types (cache + fail-open)
# ---------------------------------------------------------------------------
def _client(payload=None, raises: Exception | None = None) -> AsyncMock:
"""An object with an async ``capabilities()`` method (AsyncMock-backed)."""
m = AsyncMock()
if raises is not None:
m.capabilities.side_effect = raises
else:
m.capabilities.return_value = payload
return m
async def test_allowed_doc_types_parses_and_caches():
clear_cache()
client = _client(_payload(["note", "file"]))
first = await allowed_doc_types(client, "alice")
second = await allowed_doc_types(client, "alice")
assert first == frozenset({"note", "file"})
assert second == frozenset({"note", "file"})
# Second call served from the cache — only one OCS round-trip.
assert client.capabilities.await_count == 1
async def test_allowed_doc_types_missing_block_returns_none():
clear_cache()
client = _client({"ocs": {"data": {"capabilities": {}}}})
assert await allowed_doc_types(client, "bob") is None
async def test_allowed_doc_types_fail_open_not_cached():
clear_cache()
client = _client(raises=RuntimeError("ocs down"))
assert await allowed_doc_types(client, "carol") is None
# Failures are not cached — the next call retries the OCS lookup.
assert await allowed_doc_types(client, "carol") is None
assert client.capabilities.await_count == 2
async def test_allowed_doc_types_cache_is_per_user():
clear_cache()
alice = _client(_payload(["note"]))
bob = _client(_payload(["file"]))
assert await allowed_doc_types(alice, "alice") == frozenset({"note"})
assert await allowed_doc_types(bob, "bob") == frozenset({"file"})
async def test_allowed_doc_types_refetches_after_ttl(monkeypatch):
clear_cache()
client = _client(_payload(["note"]))
# Drive the module clock so the second call lands past the TTL window.
clock = {"now": 1000.0}
monkeypatch.setattr(cap.time, "monotonic", lambda: clock["now"])
await allowed_doc_types(client, "erin")
clock["now"] += cap._CACHE_TTL_SECONDS + 1
await allowed_doc_types(client, "erin")
assert client.capabilities.await_count == 2
async def test_clear_cache_forces_refetch():
clear_cache()
client = _client(_payload(["note"]))
await allowed_doc_types(client, "dave")
cap.clear_cache()
await allowed_doc_types(client, "dave")
assert client.capabilities.await_count == 2
+69 -1
View File
@@ -72,7 +72,7 @@ def test_nested_exception_group_descends_to_leaf():
def test_qdrant_namespace_classified():
from qdrant_client.http.exceptions import UnexpectedResponse
exc = UnexpectedResponse(500, "err", b"", headers=None)
exc = UnexpectedResponse(500, "err", b"", headers=httpx.Headers())
assert processor._drop_reason(exc) == "qdrant"
@@ -109,3 +109,71 @@ async def test_process_document_records_drop_on_exhausted_retries(mocker):
await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1)
rec.assert_called_once_with("connection")
@pytest.mark.unit
async def test_process_document_drops_admin_disabled_index_task(mocker):
"""A near-real-time index task for an admin-disabled doc_type is dropped
before indexing, and recorded under the ``admin_disabled`` reason."""
from nextcloud_mcp_server.vector.scanner import DocumentTask
doc_task = DocumentTask(
user_id="alice",
doc_id="42",
doc_type="note",
operation="index",
modified_at=0,
file_path="/x.md", # set so the tag-reconcile branch is skipped
)
mocker.patch.object(
processor,
"get_qdrant_client",
mocker.AsyncMock(return_value=mocker.MagicMock()),
)
# Admin disabled everything → note is not allowed.
mocker.patch.object(
processor, "allowed_doc_types", mocker.AsyncMock(return_value=frozenset())
)
index = mocker.patch.object(processor, "_index_document")
rec = mocker.patch.object(processor, "record_ingest_dropped")
await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1)
index.assert_not_called()
rec.assert_called_once_with("admin_disabled")
@pytest.mark.unit
async def test_process_document_allows_when_doc_type_approved(mocker):
"""The consent gate does not drop an index task for an allowed doc_type."""
from nextcloud_mcp_server.vector.scanner import DocumentTask
doc_task = DocumentTask(
user_id="alice",
doc_id="42",
doc_type="note",
operation="index",
modified_at=0,
file_path="/x.md",
)
mocker.patch.object(
processor,
"get_qdrant_client",
mocker.AsyncMock(return_value=mocker.MagicMock()),
)
mocker.patch.object(
processor,
"allowed_doc_types",
mocker.AsyncMock(return_value=frozenset({"note"})),
)
index = mocker.patch.object(
processor, "_index_document", mocker.AsyncMock(return_value=1)
)
rec = mocker.patch.object(processor, "record_ingest_dropped")
await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1)
index.assert_awaited() # indexing proceeded
rec.assert_not_called()
+258
View File
@@ -0,0 +1,258 @@
"""Unit tests for the /api/v1/vector-sync/purge admin route.
The purge is global and destructive (deletes every owner's content for a doc
type), so the route must: authenticate the bearer, restrict to Nextcloud
admins, validate the body, and only then delegate to the global purge.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.vector_sync import purge_doc_types_route
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
pytestmark = pytest.mark.unit
def _build_app() -> Starlette:
app = Starlette(
routes=[
Route(
"/api/v1/vector-sync/purge",
purge_doc_types_route,
methods=["POST"],
)
]
)
app.state.oauth_context = {"config": {"nextcloud_host": "http://nc.test"}}
return app
def _patch_token(mocker, user_id="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.validate_token_and_get_user",
new=AsyncMock(return_value=(user_id, {"sub": user_id})),
)
def _patch_basic_auth(mocker, username="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.get_basic_auth_for_user",
new=AsyncMock(return_value=(username, "app-pwd")),
)
def _patch_outbound_client(mocker):
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.nextcloud_httpx_client",
MagicMock(return_value=client),
)
return client
def _patch_groups(mocker, groups):
instance = MagicMock()
instance.get_user_groups = AsyncMock(return_value=groups)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.UsersClient",
MagicMock(return_value=instance),
)
def _patch_purge(mocker, result=None):
return mocker.patch(
"nextcloud_mcp_server.api.vector_sync.purge_doc_types",
new=AsyncMock(return_value=result or {}),
)
def test_unauthorized_when_token_invalid(mocker):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.validate_token_and_get_user",
new=AsyncMock(side_effect=ValueError("bad token")),
)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 401
purge.assert_not_called()
def test_bad_request_when_doc_types_not_list(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": "file"})
assert resp.status_code == 400
purge.assert_not_called()
def test_bad_request_when_doc_types_has_non_string(mocker):
# Covers the all(isinstance(d, str)) branch (a list with non-string items).
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": [1, 2]})
assert resp.status_code == 400
purge.assert_not_called()
def test_total_failure_returns_500(mocker):
# purge_doc_types raising (total failure) hits the route's except -> 500.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.purge_doc_types",
new=AsyncMock(side_effect=RuntimeError("qdrant down")),
)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 500
def test_forbidden_when_not_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["users"]) # not an admin
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 403
purge.assert_not_called()
def test_missing_doc_types_key_returns_400(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={})
assert resp.status_code == 400
purge.assert_not_called()
def test_empty_doc_types_is_admin_gated_noop(mocker):
# An empty (no-op) request still requires admin — this is a destructive route.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": []})
assert resp.status_code == 200
assert resp.json() == {"purged": {}}
purge.assert_not_called()
def test_empty_doc_types_forbidden_for_non_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["users"])
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": []})
assert resp.status_code == 403
purge.assert_not_called()
def test_admin_purge_happy_path(mocker):
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
purge = _patch_purge(mocker, {"file": 12})
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 200
assert resp.json() == {"purged": {"file": 12}}
purge.assert_awaited_once_with(["file"])
def test_partial_failure_reports_failed_types(mocker):
# purge_doc_types returns only the succeeded types; the route must tell the
# caller which requested types were NOT purged.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
_patch_purge(mocker, {"file": 3}) # "note" failed
client = TestClient(_build_app())
resp = client.post(
"/api/v1/vector-sync/purge", json={"doc_types": ["file", "note"]}
)
assert resp.status_code == 200
body = resp.json()
assert body["purged"] == {"file": 3}
assert body["failed"] == ["note"]
def test_bad_request_when_body_not_object(mocker):
# A valid JSON non-object (e.g. a list) must 400, not 500.
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json=[1, 2, 3])
assert resp.status_code == 400
purge.assert_not_called()
def test_bad_request_when_too_many_doc_types(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post(
"/api/v1/vector-sync/purge",
json={"doc_types": [f"t{i}" for i in range(65)]},
)
assert resp.status_code == 400
purge.assert_not_called()
def test_provisioning_required_returns_428(mocker):
_patch_token(mocker, "admin")
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.get_basic_auth_for_user",
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 428
purge.assert_not_called()
+99
View File
@@ -0,0 +1,99 @@
"""Unit tests for global purge-by-doc-type (admin consent enforcement)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import nextcloud_mcp_server.vector.purge as purge_module
from nextcloud_mcp_server.vector.purge import purge_doc_types
pytestmark = pytest.mark.unit
def _patch_qdrant(monkeypatch, *, counts: dict[str, int], delete_raises=None):
"""Wire a fake Qdrant client whose ``count`` reflects ``counts`` per
doc_type (read off the filter's MatchValue) and whose ``delete`` optionally
raises for given doc_types."""
client = AsyncMock()
def _doc_type_of(flt):
return flt.must[0].match.value
# Sync side_effects: AsyncMock awaits the call and returns the value, so the
# helpers don't need to be coroutines themselves.
def fake_count(*, collection_name, count_filter, exact):
return SimpleNamespace(count=counts.get(_doc_type_of(count_filter), 0))
def fake_delete(*, collection_name, points_selector):
dt = _doc_type_of(points_selector)
if delete_raises and dt in delete_raises:
raise RuntimeError(f"delete failed for {dt}")
client.count.side_effect = fake_count
client.delete.side_effect = fake_delete
monkeypatch.setattr(
purge_module, "get_qdrant_client", AsyncMock(return_value=client)
)
monkeypatch.setattr(
purge_module,
"get_settings",
lambda: SimpleNamespace(get_collection_name=lambda: "test_collection"),
)
return client
async def test_purges_each_doc_type_and_reports_counts(monkeypatch):
client = _patch_qdrant(monkeypatch, counts={"file": 7, "note": 3})
result = await purge_doc_types(["file", "note"])
assert result == {"file": 7, "note": 3}
assert client.delete.await_count == 2
async def test_purge_is_owner_agnostic_global(monkeypatch):
# The admin disable is global, so the delete filter must match by doc_type
# ONLY — no owner_id/user_id condition that would scope it to one user.
client = _patch_qdrant(monkeypatch, counts={"file": 1})
await purge_doc_types(["file"])
flt = client.delete.await_args.kwargs["points_selector"]
keys = [c.key for c in flt.must]
assert keys == ["doc_type"]
assert flt.must[0].match.value == "file"
async def test_dedupes_doc_types(monkeypatch):
client = _patch_qdrant(monkeypatch, counts={"file": 2})
result = await purge_doc_types(["file", "file"])
assert result == {"file": 2}
assert client.delete.await_count == 1
async def test_zero_points_is_safe(monkeypatch):
_patch_qdrant(monkeypatch, counts={})
assert await purge_doc_types(["deck_card"]) == {"deck_card": 0}
async def test_partial_failure_returns_partial(monkeypatch):
_patch_qdrant(
monkeypatch,
counts={"file": 5, "note": 4},
delete_raises={"note"},
)
# "note" delete fails, "file" succeeds — partial progress is returned.
result = await purge_doc_types(["file", "note"])
assert result == {"file": 5}
async def test_total_failure_raises(monkeypatch):
_patch_qdrant(monkeypatch, counts={"file": 5}, delete_raises={"file"})
with pytest.raises(RuntimeError):
await purge_doc_types(["file"])
@@ -0,0 +1,151 @@
"""Unit tests for the scanner's admin-consent backstop deletion.
When an admin disables a text source (note/news_item/deck_card), the scanner
skips its scan_* function, so the in-function deletion-tracking never runs. The
backstop enqueues deletes for any indexed points of the disabled type, mirroring
the files path — but only on a concrete allow-set (never on fail-open None).
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock
import pytest
from nextcloud_mcp_server.vector import scanner as scanner_module
from nextcloud_mcp_server.vector.queue.ports import TaskProducer
from nextcloud_mcp_server.vector.scanner import _enqueue_deletes_for_disabled_types
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def _clear_backstop_state():
"""The one-shot guard is module-level; reset it between tests."""
scanner_module._consent_backstop_done.clear()
yield
scanner_module._consent_backstop_done.clear()
def _producer(send: AsyncMock) -> TaskProducer:
"""A minimal stand-in for the TaskProducer protocol (only ``send`` is used)."""
return cast(TaskProducer, SimpleNamespace(send=send))
def _patch_qdrant(monkeypatch, points_by_type: dict[str, list[str]]):
client = AsyncMock()
def fake_scroll(
*, collection_name, scroll_filter, with_payload, with_vectors, limit, offset
):
# must=[user_id, doc_type] — doc_type is the second condition.
doc_type = scroll_filter.must[1].match.value
points = [
SimpleNamespace(payload={"doc_id": doc_id})
for doc_id in points_by_type.get(doc_type, [])
]
return (points, None)
client.scroll.side_effect = fake_scroll
monkeypatch.setattr(
scanner_module, "get_qdrant_client", AsyncMock(return_value=client)
)
monkeypatch.setattr(
scanner_module,
"get_settings",
lambda: SimpleNamespace(get_collection_name=lambda: "c"),
)
async def test_enqueues_deletes_for_disabled_text_type(monkeypatch):
_patch_qdrant(monkeypatch, {"note": ["n1", "n2"], "deck_card": ["d1"]})
sent: list = []
stream = _producer(AsyncMock(side_effect=lambda t: sent.append(t)))
# note disabled; news_item + deck_card still allowed.
allowed = frozenset({"file", "news_item", "deck_card"})
queued = await _enqueue_deletes_for_disabled_types("alice", stream, allowed, 1)
assert queued == 2
assert {t.doc_id for t in sent} == {"n1", "n2"}
assert all(t.operation == "delete" and t.doc_type == "note" for t in sent)
async def test_all_text_types_disabled_enqueues_all(monkeypatch):
# Admin disabled everything at once (empty allow-set): every text type's
# indexed points are enqueued for deletion in a single call.
_patch_qdrant(
monkeypatch, {"note": ["n1"], "news_item": ["ni1"], "deck_card": ["d1"]}
)
sent: list = []
stream = _producer(AsyncMock(side_effect=lambda t: sent.append(t)))
queued = await _enqueue_deletes_for_disabled_types("alice", stream, frozenset(), 1)
assert queued == 3
assert {(t.doc_type, t.doc_id) for t in sent} == {
("note", "n1"),
("news_item", "ni1"),
("deck_card", "d1"),
}
async def test_noop_when_allowed_is_none(monkeypatch):
# Fail-open: a transient capability read must never trigger deletion.
send = AsyncMock()
queued = await _enqueue_deletes_for_disabled_types(
"alice", _producer(send), None, 1
)
assert queued == 0
send.assert_not_called()
async def test_noop_when_all_text_types_allowed(monkeypatch):
send = AsyncMock()
allowed = frozenset({"note", "news_item", "deck_card", "file"})
queued = await _enqueue_deletes_for_disabled_types(
"alice", _producer(send), allowed, 1
)
assert queued == 0
send.assert_not_called()
async def test_one_shot_does_not_reflood_on_subsequent_scans(monkeypatch):
_patch_qdrant(monkeypatch, {"note": ["n1", "n2"]})
send = AsyncMock()
allowed = frozenset({"file"}) # note disabled
first = await _enqueue_deletes_for_disabled_types(
"alice", _producer(send), allowed, 1
)
second = await _enqueue_deletes_for_disabled_types(
"alice", _producer(send), allowed, 2
)
assert first == 2
# Standing disable: the next scan must not re-enqueue the same deletes.
assert second == 0
async def test_re_enable_then_disable_retriggers_backstop(monkeypatch):
_patch_qdrant(monkeypatch, {"note": ["n1"]})
send = AsyncMock()
disabled = frozenset({"file"})
enabled = frozenset({"file", "note"})
assert (
await _enqueue_deletes_for_disabled_types("alice", _producer(send), disabled, 1)
== 1
)
# Re-enabled: clears the one-shot marker.
assert (
await _enqueue_deletes_for_disabled_types("alice", _producer(send), enabled, 2)
== 0
)
# Disabled again: backstop fires once more.
assert (
await _enqueue_deletes_for_disabled_types("alice", _producer(send), disabled, 3)
== 1
)