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
@@ -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