fix(vector): gate scanner app polls on per-user enabled apps

The vector-sync scanner polled every indexed app (Notes, Files, News,
Deck) for every provisioned user on each scan cycle. When a user lacks
an app, its REST API returns 404; these were caught (indexing
continued) but flooded tenant logs with repeated 404s, scaling with
users x disabled-apps x scan-frequency and masking real failures.

Add NextcloudClient.get_enabled_apps(), which reads the per-user
/ocs/v2.php/core/navigation/apps endpoint (respects group
restrictions). Chosen over /cloud/capabilities because the News app
advertises no capability and never appears there.

scan_user_documents now resolves the enabled-app set once per cycle and
skips the Notes/News/Deck scans for apps the user lacks. Files stays
unconditional (core Tags API, not a 404 source). Detection failures
fall back to scanning every app (prior behaviour), so a transient
nav-endpoint blip never silently halts indexing; the per-app 404 guards
remain as the safety net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-07 19:57:00 +02:00
co-authored by Claude Opus 4.8
parent 90d2192347
commit 2e609cbea7
4 changed files with 243 additions and 42 deletions
+65 -1
View File
@@ -7,7 +7,7 @@ folders) into a flat list of files.
"""
from typing import Any
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -31,6 +31,70 @@ def _make_client() -> Any:
pytestmark = pytest.mark.unit
def _navigation_response(entries: list[dict]) -> MagicMock:
"""Build a mocked OCS v2 ``core/navigation/apps`` response."""
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
response.json.return_value = {"ocs": {"meta": {}, "data": entries}}
return response
class TestGetEnabledApps:
async def test_returns_app_ids_from_navigation(self):
client = _make_client()
client._client = AsyncMock()
client._client.get = AsyncMock(
return_value=_navigation_response(
[
{"id": "files", "app": "files"},
{"id": "notes", "app": "notes"},
{"id": "deck", "app": "deck"},
{"id": "news", "app": "news"},
]
)
)
apps = await client.get_enabled_apps()
assert apps == {"files", "notes", "deck", "news"}
# Hits the per-user navigation endpoint, not capabilities.
assert (
client._client.get.await_args.args[0] == "/ocs/v2.php/core/navigation/apps"
)
async def test_unions_id_and_app_keys(self):
"""When ``id`` and ``app`` differ, both are collected so an enabled
app is never hidden by an unexpected nav-entry id."""
client = _make_client()
client._client = AsyncMock()
client._client.get = AsyncMock(
return_value=_navigation_response([{"id": "files_sharing", "app": "files"}])
)
apps = await client.get_enabled_apps()
assert apps == {"files", "files_sharing"}
async def test_empty_navigation_returns_empty_set(self):
client = _make_client()
client._client = AsyncMock()
client._client.get = AsyncMock(return_value=_navigation_response([]))
assert await client.get_enabled_apps() == set()
async def test_skips_entries_missing_both_keys(self):
client = _make_client()
client._client = AsyncMock()
client._client.get = AsyncMock(
return_value=_navigation_response(
[{"name": "Logout", "href": "/logout"}, {"app": "notes"}]
)
)
assert await client.get_enabled_apps() == {"notes"}
class TestNormaliseSearchResult:
def test_adds_leading_slash_to_path(self):
result = _normalise_search_result(
@@ -0,0 +1,59 @@
"""Unit tests for the vector scanner's enabled-app gating helper.
``scan_user_documents`` skips polling apps the user doesn't have enabled (those
polls 404 and flood tenant logs). ``_get_enabled_apps_or_none`` resolves the
enabled-app set, returning ``None`` on any failure so the caller falls back to
scanning every app (the prior behaviour) rather than silently halting indexing.
"""
from unittest.mock import AsyncMock
import pytest
from httpx import HTTPStatusError, Request, Response
from nextcloud_mcp_server.vector.scanner import _get_enabled_apps_or_none
pytestmark = pytest.mark.unit
async def test_returns_enabled_set_on_success():
nc_client = AsyncMock()
nc_client.get_enabled_apps = AsyncMock(return_value={"files", "notes"})
result = await _get_enabled_apps_or_none(nc_client, "alice", scan_id=1234)
assert result == {"files", "notes"}
async def test_returns_none_when_detection_raises(caplog):
nc_client = AsyncMock()
request = Request("GET", "http://nc.test/ocs/v2.php/core/navigation/apps")
nc_client.get_enabled_apps = AsyncMock(
side_effect=HTTPStatusError(
"boom", request=request, response=Response(503, request=request)
)
)
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.vector.scanner")
result = await _get_enabled_apps_or_none(nc_client, "alice", scan_id=1234)
# None signals scan-all fallback; the inline gate treats `None` as
# "every app enabled" so indexing never silently stops.
assert result is None
assert "scanning all apps" in caplog.text
def test_none_set_enables_every_app():
"""The gate predicate used in scan_user_documents: a None set means
detection failed, so every app must be scanned (back-compat)."""
def app_enabled(app_id: str, enabled: set[str] | None) -> bool:
return enabled is None or app_id in enabled
assert app_enabled("news", None) is True
assert app_enabled("deck", None) is True
# And a concrete set gates precisely.
assert app_enabled("news", {"notes"}) is False
assert app_enabled("notes", {"notes"}) is True