Files
mcp-nextcloud/tests/unit/test_vector_sync_purge_route.py
Chris CoutinhoandClaude Opus 4.8 7067c5fff1 test(vector-sync): close round-8 coverage gaps (500 path, non-string list)
- route test for purge_doc_types raising on total failure -> 500
- route test for doc_types list containing non-strings -> 400
- reword the capabilities move_to_end comment (no-op on new keys; needed only
  for the expired-key in-place update)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:10:54 +02:00

259 lines
7.7 KiB
Python

"""Unit tests for the /api/v1/vector-sync/purge admin route.
The purge is global and destructive (deletes every owner's content for a doc
type), so the route must: authenticate the bearer, restrict to Nextcloud
admins, validate the body, and only then delegate to the global purge.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from starlette.applications import Starlette
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
def _build_app() -> Starlette:
app = Starlette(
routes=[
Route(
"/api/v1/vector-sync/purge",
purge_doc_types_route,
methods=["POST"],
)
]
)
app.state.oauth_context = {"config": {"nextcloud_host": "http://nc.test"}}
return app
def _patch_token(mocker, user_id="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.validate_token_and_get_user",
new=AsyncMock(return_value=(user_id, {"sub": user_id})),
)
def _patch_basic_auth(mocker, username="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.get_basic_auth_for_user",
new=AsyncMock(return_value=(username, "app-pwd")),
)
def _patch_outbound_client(mocker):
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.nextcloud_httpx_client",
MagicMock(return_value=client),
)
return client
def _patch_groups(mocker, groups):
instance = MagicMock()
instance.get_user_groups = AsyncMock(return_value=groups)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.UsersClient",
MagicMock(return_value=instance),
)
def _patch_purge(mocker, result=None):
return mocker.patch(
"nextcloud_mcp_server.api.vector_sync.purge_doc_types",
new=AsyncMock(return_value=result or {}),
)
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")),
)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 401
purge.assert_not_called()
def test_bad_request_when_doc_types_not_list(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": "file"})
assert resp.status_code == 400
purge.assert_not_called()
def test_bad_request_when_doc_types_has_non_string(mocker):
# Covers the all(isinstance(d, str)) branch (a list with non-string items).
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": [1, 2]})
assert resp.status_code == 400
purge.assert_not_called()
def test_total_failure_returns_500(mocker):
# purge_doc_types raising (total failure) hits the route's except -> 500.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.purge_doc_types",
new=AsyncMock(side_effect=RuntimeError("qdrant down")),
)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 500
def test_forbidden_when_not_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["users"]) # not an admin
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 403
purge.assert_not_called()
def test_missing_doc_types_key_returns_400(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={})
assert resp.status_code == 400
purge.assert_not_called()
def test_empty_doc_types_is_admin_gated_noop(mocker):
# An empty (no-op) request still requires admin — this is a destructive route.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": []})
assert resp.status_code == 200
assert resp.json() == {"purged": {}}
purge.assert_not_called()
def test_empty_doc_types_forbidden_for_non_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["users"])
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": []})
assert resp.status_code == 403
purge.assert_not_called()
def test_admin_purge_happy_path(mocker):
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
purge = _patch_purge(mocker, {"file": 12})
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 200
assert resp.json() == {"purged": {"file": 12}}
purge.assert_awaited_once_with(["file"])
def test_partial_failure_reports_failed_types(mocker):
# purge_doc_types returns only the succeeded types; the route must tell the
# caller which requested types were NOT purged.
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
_patch_purge(mocker, {"file": 3}) # "note" failed
client = TestClient(_build_app())
resp = client.post(
"/api/v1/vector-sync/purge", json={"doc_types": ["file", "note"]}
)
assert resp.status_code == 200
body = resp.json()
assert body["purged"] == {"file": 3}
assert body["failed"] == ["note"]
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_bad_request_when_too_many_doc_types(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post(
"/api/v1/vector-sync/purge",
json={"doc_types": [f"t{i}" for i in range(65)]},
)
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()