feat(vector-sync): honor Astrolabe admin consent for searchable sources
Consume the astrolabe.semantic_search capability as the source of truth for which content sources an admin has approved for semantic search, and enforce it independently of Astrolabe (this server queries Qdrant directly). - capabilities.py: cached per-user reader for enabled_doc_types (TTL+LRU, fail-open so older Astrolabe / transient OCS errors don't break search) - semantic search: intersect requested doc_types with the allowed set; restrict to the allowed set when none requested; short-circuit when empty - scanner: skip disabled sources during discovery (files discovery yields nothing when disabled, so the existing grace-period reconcile purges them) - processor: drop near-real-time index tasks for disabled doc_types (webhook events bypass the scanner gate); deletes always proceed - vector/purge.py + POST /api/v1/vector-sync/purge: admin-only global delete-by-doc_type, called by Astrolabe when a source is disabled so consent is binding on data-at-rest Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07ee91399b
commit
ef5b3f3873
@@ -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",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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__)
|
||||
|
||||
|
||||
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 JSONResponse(
|
||||
{"error": "Bad request", "message": "invalid JSON"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
raw = body.get("doc_types")
|
||||
if not isinstance(raw, list) or not all(isinstance(d, str) for d in raw):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "doc_types must be a list of strings",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
doc_types = [d for d in raw if d]
|
||||
if not doc_types:
|
||||
return JSONResponse({"purged": {}})
|
||||
|
||||
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.
|
||||
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,
|
||||
)
|
||||
|
||||
purged = await purge_doc_types(doc_types)
|
||||
logger.info("Vector-sync purge by admin %s: %s", user_id, purged)
|
||||
return JSONResponse({"purged": purged})
|
||||
|
||||
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.error("Error purging doc types for user %s: %s", user_id, e)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "purge_doc_types"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -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,15 @@ 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)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/vector-sync/purge",
|
||||
purge_doc_types_route,
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
# Access and scope management endpoints (ADR-022)
|
||||
routes.append(
|
||||
Route(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""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.
|
||||
_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) -> set[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 set (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 {dt for dt in raw if isinstance(dt, str)}
|
||||
|
||||
|
||||
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
|
||||
|
||||
parsed = _parse_enabled_doc_types(payload)
|
||||
result = frozenset(parsed) if parsed is not None else None
|
||||
_cache[user_id] = (now, result)
|
||||
_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()
|
||||
@@ -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 (
|
||||
@@ -300,6 +301,33 @@ 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.
|
||||
allowed = await allowed_doc_types(client, username)
|
||||
if allowed is not None:
|
||||
if doc_types is None:
|
||||
doc_types = sorted(allowed)
|
||||
else:
|
||||
doc_types = [dt for dt in doc_types if dt in allowed]
|
||||
if not doc_types:
|
||||
logger.info(
|
||||
"Semantic search short-circuited for user %s: no requested "
|
||||
"doc_type is both installed and admin-approved",
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -574,6 +575,24 @@ 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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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.
|
||||
"""
|
||||
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.error(
|
||||
"Failed to purge indexed points for doc_type=%s: %s",
|
||||
doc_type,
|
||||
exc,
|
||||
)
|
||||
|
||||
if not purged and last_error is not None:
|
||||
# Nothing succeeded — surface the failure to the caller (HTTP 500).
|
||||
raise last_error
|
||||
return purged
|
||||
@@ -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
|
||||
@@ -365,6 +366,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 +381,7 @@ async def scan_user_documents(
|
||||
current_time = time.time()
|
||||
queued = 0
|
||||
|
||||
if _app_enabled("notes", enabled_apps):
|
||||
if _app_enabled("notes", enabled_apps) and is_doc_type_allowed("note", allowed):
|
||||
try:
|
||||
queued += await scan_notes(
|
||||
user_id=user_id,
|
||||
@@ -454,9 +462,21 @@ async def scan_user_documents(
|
||||
# folder applies to every PDF beneath it.
|
||||
settings = get_settings()
|
||||
tag_name = settings.vector_sync_pdf_tag
|
||||
tagged_files = await nc_client.find_files_by_tag(
|
||||
tag_name, mime_type_filter="application/pdf"
|
||||
)
|
||||
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.
|
||||
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 +730,9 @@ async def scan_user_documents(
|
||||
|
||||
# Scan News items (starred + unread)
|
||||
news_queued = 0
|
||||
if _app_enabled("news", enabled_apps):
|
||||
if _app_enabled("news", enabled_apps) and is_doc_type_allowed(
|
||||
"news_item", allowed
|
||||
):
|
||||
try:
|
||||
news_queued = await scan_news_items(
|
||||
user_id=user_id,
|
||||
@@ -731,7 +753,9 @@ async def scan_user_documents(
|
||||
|
||||
# Scan Deck cards
|
||||
deck_queued = 0
|
||||
if _app_enabled("deck", enabled_apps):
|
||||
if _app_enabled("deck", enabled_apps) and is_doc_type_allowed(
|
||||
"deck_card", allowed
|
||||
):
|
||||
try:
|
||||
deck_queued = await scan_deck_cards(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Unit tests for the Astrolabe searchable-sources capability reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, payload=None, raises: Exception | None = None):
|
||||
self._payload = payload
|
||||
self._raises = raises
|
||||
self.calls = 0
|
||||
|
||||
async def capabilities(self):
|
||||
self.calls += 1
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return self._payload
|
||||
|
||||
|
||||
async def test_allowed_doc_types_parses_and_caches():
|
||||
clear_cache()
|
||||
client = _FakeClient(_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.calls == 1
|
||||
|
||||
|
||||
async def test_allowed_doc_types_missing_block_returns_none():
|
||||
clear_cache()
|
||||
client = _FakeClient({"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 = _FakeClient(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.calls == 2
|
||||
|
||||
|
||||
async def test_allowed_doc_types_cache_is_per_user():
|
||||
clear_cache()
|
||||
alice = _FakeClient(_payload(["note"]))
|
||||
bob = _FakeClient(_payload(["file"]))
|
||||
|
||||
assert await allowed_doc_types(alice, "alice") == frozenset({"note"})
|
||||
assert await allowed_doc_types(bob, "bob") == frozenset({"file"})
|
||||
|
||||
|
||||
async def test_clear_cache_forces_refetch():
|
||||
clear_cache()
|
||||
client = _FakeClient(_payload(["note"]))
|
||||
await allowed_doc_types(client, "dave")
|
||||
cap.clear_cache()
|
||||
await allowed_doc_types(client, "dave")
|
||||
assert client.calls == 2
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
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 {}),
|
||||
)
|
||||
|
||||
|
||||
async 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()
|
||||
|
||||
|
||||
async 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()
|
||||
|
||||
|
||||
async 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()
|
||||
|
||||
|
||||
async def test_empty_doc_types_is_noop(mocker):
|
||||
_patch_token(mocker)
|
||||
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()
|
||||
|
||||
|
||||
async 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"])
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
||||
async def fake_count(*, collection_name, count_filter, exact):
|
||||
return SimpleNamespace(count=counts.get(_doc_type_of(count_filter), 0))
|
||||
|
||||
async 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
|
||||
|
||||
async def fake_get_qdrant_client():
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(purge_module, "get_qdrant_client", fake_get_qdrant_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_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"])
|
||||
Reference in New Issue
Block a user