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:
@@ -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()) == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user