fix(webhooks): use app-password basic auth for NC API calls
The webhook API endpoints in api/webhooks.py forwarded the inbound MCP OAuth bearer token directly to Nextcloud as the Authorization header. Per ADR-022 / docs/login-flow-v2.md the data leg from MCP server to Nextcloud must use HTTP Basic Auth with the user's stored Login Flow v2 app password — bearer-forwarding requires upstream user_oidc patches that were never merged and is incompatible with admin endpoints gated by @PasswordConfirmationRequired (e.g. webhook_listeners/api/v1/webhooks, which 401s). PR #760 papered over the symptom for /api/v1/apps by switching to the permissive /cloud/capabilities endpoint, but the same architectural mistake remained on list_webhooks / create_webhook / delete_webhook, which still 500'd on the astrolabe admin UI's preset page. Changes: - New helper api/_auth.py:get_basic_auth_for_user(user_id) reads the user's app password from encrypted storage and returns (username, app_password). Mirrors context.py:_get_client_from_login_flow but is callable from Starlette routes (no MCP Context required). - All four endpoints in api/webhooks.py now use httpx.BasicAuth instead of forwarding the OAuth bearer; ProvisioningRequiredError is mapped to HTTP 412 so callers can render a "complete provisioning" CTA rather than receiving an opaque 500. - Outbound NC requests now identify the user by the username recorded at Login Flow v2 provisioning time (which may differ from the IdP-issued user_id) — flowed into WebhooksClient and used for logging. Tests: - tests/unit/test_management_apps_endpoint.py: assertions updated to verify outbound NC request uses BasicAuth and carries no Authorization header. Replaced "missing-Authorization → 500" test with a ProvisioningRequiredError → 412 case. - tests/unit/test_webhooks_api_auth.py (new): cross-endpoint coverage for list_webhooks, create_webhook, delete_webhook and the new helper — including 412 symmetry for all four endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
beef77d785
commit
a0e484d95b
@@ -1,21 +1,23 @@
|
||||
"""
|
||||
Unit tests for the Management API /api/v1/apps endpoint.
|
||||
|
||||
These tests cover the regression where /api/v1/apps proxied to
|
||||
/ocs/v1.php/cloud/apps — an admin-only and @PasswordConfirmationRequired
|
||||
endpoint that always 401s for OAuth bearer tokens. The handler now uses
|
||||
/ocs/v2.php/cloud/capabilities, which accepts the bearer and returns an
|
||||
authenticated capability map keyed by app id.
|
||||
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
|
||||
|
||||
@@ -33,8 +35,19 @@ def _patch_token_validation(mocker, user_id: str = "admin") -> None:
|
||||
)
|
||||
|
||||
|
||||
def _patch_outbound_client(mocker, response: MagicMock) -> AsyncMock:
|
||||
"""Patch the outbound httpx client; return the mocked .get() AsyncMock."""
|
||||
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
|
||||
@@ -48,7 +61,6 @@ def _patch_outbound_client(mocker, response: MagicMock) -> AsyncMock:
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.webhooks.nextcloud_httpx_client", mock_factory
|
||||
)
|
||||
# Return both so tests can assert on factory kwargs and call args
|
||||
mock_factory.attach_mock(mock_get, "get")
|
||||
return mock_factory
|
||||
|
||||
@@ -63,6 +75,7 @@ def _capabilities_response(capabilities: dict[str, dict]) -> MagicMock:
|
||||
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"},
|
||||
@@ -82,22 +95,24 @@ async def test_returns_sorted_capability_keys(mocker):
|
||||
assert http_response.status_code == 200
|
||||
assert http_response.json() == {"apps": ["core", "files", "notes", "tables"]}
|
||||
|
||||
# Regression check: outbound URL is OCS v2 capabilities, NOT v1 cloud/apps.
|
||||
# /ocs/v1.php/cloud/apps was admin-only + @PasswordConfirmationRequired
|
||||
# which always 401s for OAuth bearer tokens — that was the bug.
|
||||
# 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"
|
||||
assert "/cloud/apps" not in called_path
|
||||
|
||||
# Bearer token forwarded so authenticated capabilities are returned
|
||||
factory_call_kwargs = factory.call_args.kwargs
|
||||
assert factory_call_kwargs["headers"] == {"Authorization": "Bearer test-token"}
|
||||
# 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)
|
||||
|
||||
@@ -114,6 +129,7 @@ async def test_empty_capabilities_returns_empty_list(mocker):
|
||||
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)
|
||||
@@ -135,6 +151,7 @@ async def test_ocs_error_returns_500_with_sanitized_message(mocker):
|
||||
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)
|
||||
|
||||
@@ -149,17 +166,20 @@ async def test_missing_nextcloud_host_returns_500(mocker):
|
||||
assert http_response.status_code == 500
|
||||
|
||||
|
||||
async def test_missing_authorization_returns_500(mocker):
|
||||
"""When token validation passes but Authorization header is absent, the
|
||||
handler can't construct the outbound bearer header — returns 500 with a
|
||||
sanitized message."""
|
||||
async def test_unprovisioned_user_returns_412(mocker):
|
||||
"""Users without a stored app password get HTTP 412 so the client can
|
||||
surface a 'complete Login Flow v2' UX rather than a generic 500."""
|
||||
_patch_token_validation(mocker)
|
||||
response = _capabilities_response({})
|
||||
_patch_outbound_client(mocker, response)
|
||||
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)
|
||||
# No Authorization header
|
||||
http_response = client.get("/api/v1/apps")
|
||||
http_response = client.get(
|
||||
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
|
||||
)
|
||||
|
||||
assert http_response.status_code == 500
|
||||
assert http_response.status_code == 412
|
||||
assert http_response.json()["error"] == "Provisioning required"
|
||||
|
||||
Reference in New Issue
Block a user