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