fix(vector-sync): address PR review — dict guard, symmetric backstop, metrics

- purge route: 400 (not 500) on a valid-JSON non-object body
- scanner: backstop-purge admin-disabled note/news_item/deck_card points
  (their deletion-tracking lives inside the skipped scan_* fns), mirroring the
  files path; gated on a concrete allow-set so fail-open never deletes
- processor: record_ingest_dropped("admin_disabled") so consent-skipped index
  tasks are observable/alertable
- app.py: list /api/v1/vector-sync/purge in the endpoints log line
- capabilities: drop empty-string doc types; return frozenset throughout
- purge: document the count-before-delete approximation
- tests: non-object body -> 400, ProvisioningRequiredError -> 428, cache TTL
  expiry refetch, and the scanner consent backstop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-16 00:54:50 +02:00
co-authored by Claude Opus 4.8
parent ef5b3f3873
commit 477fb02b0a
10 changed files with 258 additions and 43 deletions
+34 -20
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import nextcloud_mcp_server.capabilities as cap
from nextcloud_mcp_server.capabilities import (
_parse_enabled_doc_types,
@@ -84,22 +86,19 @@ def test_is_doc_type_allowed_empty_set_blocks_all():
# ---------------------------------------------------------------------------
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
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 = _FakeClient(_payload(["note", "file"]))
client = _client(_payload(["note", "file"]))
first = await allowed_doc_types(client, "alice")
second = await allowed_doc_types(client, "alice")
@@ -107,38 +106,53 @@ async def test_allowed_doc_types_parses_and_caches():
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
assert client.capabilities.await_count == 1
async def test_allowed_doc_types_missing_block_returns_none():
clear_cache()
client = _FakeClient({"ocs": {"data": {"capabilities": {}}}})
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 = _FakeClient(raises=RuntimeError("ocs down"))
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.calls == 2
assert client.capabilities.await_count == 2
async def test_allowed_doc_types_cache_is_per_user():
clear_cache()
alice = _FakeClient(_payload(["note"]))
bob = _FakeClient(_payload(["file"]))
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 = _FakeClient(_payload(["note"]))
client = _client(_payload(["note"]))
await allowed_doc_types(client, "dave")
cap.clear_cache()
await allowed_doc_types(client, "dave")
assert client.calls == 2
assert client.capabilities.await_count == 2
+33 -5
View File
@@ -13,6 +13,7 @@ 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
@@ -72,7 +73,7 @@ def _patch_purge(mocker, result=None):
)
async def test_unauthorized_when_token_invalid(mocker):
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")),
@@ -86,7 +87,7 @@ async def test_unauthorized_when_token_invalid(mocker):
purge.assert_not_called()
async def test_bad_request_when_doc_types_not_list(mocker):
def test_bad_request_when_doc_types_not_list(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
@@ -97,7 +98,7 @@ async def test_bad_request_when_doc_types_not_list(mocker):
purge.assert_not_called()
async def test_forbidden_when_not_admin(mocker):
def test_forbidden_when_not_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
@@ -111,7 +112,7 @@ async def test_forbidden_when_not_admin(mocker):
purge.assert_not_called()
async def test_empty_doc_types_is_noop(mocker):
def test_empty_doc_types_is_noop(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
@@ -123,7 +124,7 @@ async def test_empty_doc_types_is_noop(mocker):
purge.assert_not_called()
async def test_admin_purge_happy_path(mocker):
def test_admin_purge_happy_path(mocker):
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
@@ -136,3 +137,30 @@ async def test_admin_purge_happy_path(mocker):
assert resp.status_code == 200
assert resp.json() == {"purged": {"file": 12}}
purge.assert_awaited_once_with(["file"])
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_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()
+7 -6
View File
@@ -20,10 +20,12 @@ def _patch_qdrant(monkeypatch, *, counts: dict[str, int], delete_raises=None):
def _doc_type_of(flt):
return flt.must[0].match.value
async def fake_count(*, collection_name, count_filter, exact):
# 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))
async def fake_delete(*, collection_name, points_selector):
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}")
@@ -31,10 +33,9 @@ def _patch_qdrant(monkeypatch, *, counts: dict[str, int], delete_raises=None):
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_qdrant_client", AsyncMock(return_value=client)
)
monkeypatch.setattr(
purge_module,
"get_settings",
@@ -0,0 +1,81 @@
"""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
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
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_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()