Merge pull request #873 from cbcoutinho/fix/scanner-gate-enabled-apps

fix(vector): gate scanner app polls on per-user enabled apps
This commit is contained in:
Chris Coutinho
2026-06-07 21:14:46 +02:00
committed by GitHub
4 changed files with 312 additions and 42 deletions
+40
View File
@@ -199,6 +199,46 @@ class NextcloudClient:
return response.json()
async def get_enabled_apps(self) -> set[str]:
"""Return the set of app ids enabled for the authenticated user.
Uses the per-user core navigation endpoint, which lists only apps the
current user can access (respecting group restrictions). The vector
scanner uses this to skip polling apps the user lacks, which would 404
and flood tenant logs. Preferred over ``/cloud/capabilities`` because
the News app advertises no capability and so never appears there.
"""
response = await self._client.get(
"/ocs/v2.php/core/navigation/apps",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
# ``X or {}``/``or []`` (not ``.get(k, default)``) so a present-but-null
# ``ocs``/``data`` (``{"ocs": null}``) coerces to empty instead of
# raising AttributeError on ``None.get``.
ocs = data.get("ocs") or {}
# A 200 carrying ``meta.status != "ok"`` is an OCS-level failure (auth /
# permission edge cases) that ``raise_for_status`` can't see. Raise so
# the scanner's ``_get_enabled_apps_or_none`` catches it and falls back
# to scanning every app, rather than silently gating all apps off for a
# cycle on an empty ``data``. Tolerate a missing/empty meta (our own
# mocks, and any envelope that omits it).
status = (ocs.get("meta") or {}).get("status")
if status and status != "ok": # falsy (missing/None/"") tolerated
raise ValueError(f"OCS navigation returned status={status!r}")
entries = ocs.get("data") or []
enabled: set[str] = set()
for entry in entries:
# ``app`` is the canonical app id; ``id`` matches it for the apps we
# gate. Union both so an unexpected nav-entry shape never hides an
# enabled app — a false "disabled" would skip real indexing.
for key in ("app", "id"):
value = entry.get(key)
if value:
enabled.add(value)
return enabled
async def notes_search_notes(self, *, query: str):
"""Search notes using token-based matching with relevance ranking."""
all_notes = self.notes.get_all_notes()
+58
View File
@@ -247,6 +247,38 @@ async def scanner_task(
logger.info("Scanner task stopped - stream closed")
async def _get_enabled_apps_or_none(
nc_client: NextcloudClient, user_id: str, scan_id: int
) -> set[str] | None:
"""Enabled-app id set for gating, or ``None`` when detection fails.
``None`` signals "couldn't determine" — callers must then scan every app
(the prior behaviour), so a transient navigation-endpoint failure never
silently halts indexing. The per-app 404 guards in ``scan_user_documents``
remain the safety net for that fallback path.
"""
try:
return await nc_client.get_enabled_apps()
except Exception as e:
logger.warning(
"[SCAN-%s] Could not determine enabled apps for %s (%s); scanning all apps",
scan_id,
user_id,
e,
)
return None
def _app_enabled(app_id: str, enabled_apps: set[str] | None) -> bool:
"""Whether ``app_id`` should be scanned for the current user.
``enabled_apps is None`` means detection failed — every app is treated as
enabled (the scan-all fallback) so a transient navigation-endpoint failure
never silently halts indexing.
"""
return enabled_apps is None or app_id in enabled_apps
async def scan_user_documents(
user_id: str,
send_stream: TaskProducer,
@@ -328,6 +360,11 @@ async def scan_user_documents(
logger.debug("Found %s indexed documents in Qdrant", len(indexed_doc_ids))
# Determine which apps are enabled for this user so we skip polling
# apps they lack — those polls 404 and flood tenant logs. ``None`` means
# detection failed: fall back to scanning every app (prior behaviour).
enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id)
# Notes (isolated so an uninstalled or disabled Notes app — whose API
# returns 404 — cannot abort scanning of the other apps; this mirrors the
# per-app try/except guards already wrapping files/news/deck below).
@@ -336,6 +373,7 @@ async def scan_user_documents(
current_time = time.time()
queued = 0
if _app_enabled("notes", enabled_apps):
try:
queued += await scan_notes(
user_id=user_id,
@@ -359,6 +397,12 @@ async def scan_user_documents(
logger.warning("Failed to scan notes for %s: %s", user_id, e)
except Exception as e:
logger.warning("Failed to scan notes for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] Notes app not enabled for %s; skipping notes",
scan_id,
user_id,
)
if initial_sync:
logger.info("Sent %s documents for initial sync: %s", queued, user_id)
@@ -666,6 +710,7 @@ async def scan_user_documents(
# Scan News items (starred + unread)
news_queued = 0
if _app_enabled("news", enabled_apps):
try:
news_queued = await scan_news_items(
user_id=user_id,
@@ -677,9 +722,16 @@ async def scan_user_documents(
queued += news_queued
except Exception as e:
logger.warning("Failed to scan news items for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] News app not enabled for %s; skipping news items",
scan_id,
user_id,
)
# Scan Deck cards
deck_queued = 0
if _app_enabled("deck", enabled_apps):
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
@@ -691,6 +743,12 @@ async def scan_user_documents(
queued += deck_queued
except Exception as e:
logger.warning("Failed to scan deck cards for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] Deck app not enabled for %s; skipping deck cards",
scan_id,
user_id,
)
if queued > 0:
logger.info(
+98 -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,103 @@ 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"}
@pytest.mark.parametrize("body", [{}, {"ocs": None}, {"ocs": {"data": None}}])
async def test_malformed_envelope_returns_empty_set(self, body):
"""A missing/null ``ocs``/``data`` envelope yields an empty set rather
than raising. NOTE: an empty set does NOT trigger the
``_get_enabled_apps_or_none`` scan-all fallback (that fires only on
exceptions) — all optional apps are gated off for this scan cycle, with
Files unaffected (unconditional) and the next cycle retrying normally."""
client = _make_client()
response = MagicMock()
response.raise_for_status = MagicMock()
response.json.return_value = body
client._client = AsyncMock()
client._client.get = AsyncMock(return_value=response)
assert await client.get_enabled_apps() == set()
async def test_ocs_failure_status_raises(self):
"""A 200 with ``ocs.meta.status == "failure"`` raises so the scanner's
``_get_enabled_apps_or_none`` falls back to scanning all apps, instead
of silently gating every app off on the empty ``data`` of a failure
envelope."""
client = _make_client()
response = MagicMock()
response.raise_for_status = MagicMock()
response.json.return_value = {
"ocs": {"meta": {"status": "failure", "statuscode": 997}, "data": None}
}
client._client = AsyncMock()
client._client.get = AsyncMock(return_value=response)
with pytest.raises(ValueError, match="failure"):
await client.get_enabled_apps()
class TestNormaliseSearchResult:
def test_adds_leading_slash_to_path(self):
result = _normalise_search_result(
@@ -0,0 +1,75 @@
"""Unit tests for the vector scanner's enabled-app gating helpers.
``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;
``_app_enabled`` is the gate predicate applied per app.
"""
import logging
from unittest.mock import AsyncMock
import pytest
from httpx import HTTPStatusError, Request, Response
from nextcloud_mcp_server.vector.scanner import (
_app_enabled,
_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", "https://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)
)
)
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; _app_enabled treats `None` as
# "every app enabled" so indexing never silently stops.
assert result is None
assert "scanning all apps" in caplog.text
async def test_value_error_from_ocs_failure_returns_none():
"""A ValueError (e.g. OCS meta.status=='failure' from get_enabled_apps)
routes through the scan-all fallback like any other exception."""
nc_client = AsyncMock()
nc_client.get_enabled_apps = AsyncMock(
side_effect=ValueError("OCS navigation returned status='failure'")
)
result = await _get_enabled_apps_or_none(nc_client, "alice", scan_id=1234)
assert result is None
def test_none_set_enables_every_app():
"""A None set means detection failed, so every app must be scanned."""
assert _app_enabled("news", None) is True
assert _app_enabled("deck", None) is True
def test_concrete_set_gates_precisely():
"""A resolved set scans only the apps it contains."""
enabled = {"notes", "files"}
assert _app_enabled("notes", enabled) is True
assert _app_enabled("news", enabled) is False
assert _app_enabled("deck", enabled) is False