428 (Precondition Required, RFC 6585) is the correct semantic — the request requires the client to complete a prerequisite step (Login Flow v2 provisioning) before retrying. 412 (Precondition Failed) is for header-based preconditions like ETags / If-Match. No behavior change beyond the status code; same JSON payload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""
|
|
Unit tests for the Management API /api/v1/apps endpoint.
|
|
|
|
The handler hits ``/ocs/v2.php/cloud/capabilities`` (not the legacy admin-only
|
|
``/ocs/v1.php/cloud/apps`` which was ``@PasswordConfirmationRequired``) and
|
|
authenticates via the user's stored Login Flow v2 app password using HTTP
|
|
Basic Auth. The OAuth bearer is **never** forwarded to Nextcloud (see
|
|
``docs/login-flow-v2.md`` and ADR-022).
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from nextcloud_mcp_server.api.webhooks import get_installed_apps
|
|
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _build_test_app() -> Starlette:
|
|
app = Starlette(routes=[Route("/api/v1/apps", get_installed_apps, methods=["GET"])])
|
|
app.state.oauth_context = {"config": {"nextcloud_host": "http://nc.test"}}
|
|
return app
|
|
|
|
|
|
def _patch_token_validation(mocker, user_id: str = "admin") -> None:
|
|
mocker.patch(
|
|
"nextcloud_mcp_server.api.webhooks.validate_token_and_get_user",
|
|
new=AsyncMock(return_value=(user_id, {"sub": user_id})),
|
|
)
|
|
|
|
|
|
def _patch_basic_auth(
|
|
mocker, username: str = "admin", app_password: str = "stored-app-pwd"
|
|
) -> AsyncMock:
|
|
"""Patch get_basic_auth_for_user to return canned credentials."""
|
|
return mocker.patch(
|
|
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
|
|
new=AsyncMock(return_value=(username, app_password)),
|
|
)
|
|
|
|
|
|
def _patch_outbound_client(mocker, response: MagicMock) -> MagicMock:
|
|
"""Patch the outbound httpx client; return the factory MagicMock so tests
|
|
can introspect the kwargs (esp. ``auth=``) it was called with."""
|
|
mock_get = AsyncMock(return_value=response)
|
|
mock_client = AsyncMock()
|
|
mock_client.get = mock_get
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
# Must return False (or None) so async-with does NOT suppress exceptions
|
|
# raised inside the block — default AsyncMock() resolves to a truthy
|
|
# MagicMock and would silently swallow ValueError.
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
mock_factory = MagicMock(return_value=mock_client)
|
|
mocker.patch(
|
|
"nextcloud_mcp_server.api.webhooks.nextcloud_httpx_client", mock_factory
|
|
)
|
|
mock_factory.attach_mock(mock_get, "get")
|
|
return mock_factory
|
|
|
|
|
|
def _capabilities_response(capabilities: dict[str, dict]) -> MagicMock:
|
|
response = MagicMock()
|
|
response.status_code = 200
|
|
response.json.return_value = {"ocs": {"data": {"capabilities": capabilities}}}
|
|
return response
|
|
|
|
|
|
async def test_returns_sorted_capability_keys(mocker):
|
|
"""Happy path: handler hits OCS v2 capabilities and returns sorted app keys."""
|
|
_patch_token_validation(mocker)
|
|
_patch_basic_auth(mocker)
|
|
response = _capabilities_response(
|
|
{
|
|
"notes": {"api_version": "1.4"},
|
|
"files": {"bigfilechunking": True},
|
|
"core": {"webdav-root": "remote.php/webdav"},
|
|
"tables": {"foo": "bar"},
|
|
}
|
|
)
|
|
factory = _patch_outbound_client(mocker, response)
|
|
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
http_response = client.get(
|
|
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
|
)
|
|
|
|
assert http_response.status_code == 200
|
|
assert http_response.json() == {"apps": ["core", "files", "notes", "tables"]}
|
|
|
|
# Outbound URL is OCS v2 capabilities, NOT v1 cloud/apps.
|
|
factory.get.assert_awaited_once()
|
|
called_path = factory.get.call_args.args[0]
|
|
assert called_path == "/ocs/v2.php/cloud/capabilities"
|
|
|
|
# Outbound auth is BasicAuth (NOT Bearer) — the OAuth token must not be
|
|
# forwarded to Nextcloud per ADR-022 / docs/login-flow-v2.md.
|
|
factory_kwargs = factory.call_args.kwargs
|
|
assert "headers" not in factory_kwargs or "Authorization" not in (
|
|
factory_kwargs.get("headers") or {}
|
|
)
|
|
assert isinstance(factory_kwargs["auth"], httpx.BasicAuth)
|
|
|
|
|
|
async def test_empty_capabilities_returns_empty_list(mocker):
|
|
"""Empty capabilities map → empty apps list, not an error."""
|
|
_patch_token_validation(mocker)
|
|
_patch_basic_auth(mocker)
|
|
response = _capabilities_response({})
|
|
_patch_outbound_client(mocker, response)
|
|
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
http_response = client.get(
|
|
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
|
)
|
|
|
|
assert http_response.status_code == 200
|
|
assert http_response.json() == {"apps": []}
|
|
|
|
|
|
async def test_ocs_error_returns_500_with_sanitized_message(mocker):
|
|
"""Non-200 from Nextcloud OCS surfaces as a generic 500 to the caller."""
|
|
_patch_token_validation(mocker)
|
|
_patch_basic_auth(mocker)
|
|
error_response = MagicMock()
|
|
error_response.status_code = 503
|
|
_patch_outbound_client(mocker, error_response)
|
|
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
http_response = client.get(
|
|
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
|
)
|
|
|
|
assert http_response.status_code == 500
|
|
body = http_response.json()
|
|
assert body["error"] == "Internal error"
|
|
# _sanitize_error_for_client returns a generic message; specific status
|
|
# code must NOT leak to the client
|
|
assert "503" not in body["message"]
|
|
|
|
|
|
async def test_missing_nextcloud_host_returns_500(mocker):
|
|
"""Misconfigured oauth_context (no nextcloud_host) surfaces as 500."""
|
|
_patch_token_validation(mocker)
|
|
_patch_basic_auth(mocker)
|
|
response = _capabilities_response({})
|
|
_patch_outbound_client(mocker, response)
|
|
|
|
app = Starlette(routes=[Route("/api/v1/apps", get_installed_apps, methods=["GET"])])
|
|
app.state.oauth_context = {"config": {}} # no nextcloud_host
|
|
|
|
client = TestClient(app)
|
|
http_response = client.get(
|
|
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
|
)
|
|
|
|
assert http_response.status_code == 500
|
|
|
|
|
|
async def test_unprovisioned_user_returns_428(mocker):
|
|
"""Users without a stored app password get HTTP 428 (Precondition Required,
|
|
RFC 6585) so the client can surface a 'complete Login Flow v2' UX rather
|
|
than a generic 500. 428 is the right semantic — the request requires a
|
|
prerequisite step (provisioning) before it can succeed."""
|
|
_patch_token_validation(mocker)
|
|
mocker.patch(
|
|
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
|
|
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
|
|
)
|
|
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
http_response = client.get(
|
|
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
|
)
|
|
|
|
assert http_response.status_code == 428
|
|
assert http_response.json()["error"] == "Provisioning required"
|