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(