- 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>
259 lines
7.7 KiB
Python
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()
|