"""Unit tests asserting the webhook API endpoints authenticate to Nextcloud with an app-password BasicAuth credential — never by forwarding the inbound OAuth bearer token. Per ADR-022 / ``docs/login-flow-v2.md`` the data leg from MCP server to Nextcloud always uses HTTP Basic Auth with a per-user app password obtained via Login Flow v2. Forwarding OAuth bearers to Nextcloud was the obsolete pre-ADR-022 pattern; it relies on upstream user_oidc patches that were never merged and is incompatible with admin endpoints gated by ``@PasswordConfirmationRequired`` (e.g. ``webhook_listeners``). """ 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 ( create_webhook, delete_webhook, list_webhooks, ) from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError from nextcloud_mcp_server.auth.webhook_routes import WebhookSecretNotConfigured pytestmark = pytest.mark.unit def _build_test_app() -> Starlette: app = Starlette( routes=[ Route("/api/v1/webhooks", list_webhooks, methods=["GET"]), Route("/api/v1/webhooks", create_webhook, methods=["POST"]), Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"]), ] ) 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: return mocker.patch( "nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user", new=AsyncMock(return_value=(username, app_password)), ) def _patch_webhooks_client(mocker, **methods) -> MagicMock: """Replace WebhooksClient with a stub. ``methods`` maps method name → return value for AsyncMock(side_effect/return_value).""" instance = MagicMock() for name, value in methods.items(): setattr(instance, name, AsyncMock(return_value=value)) cls = MagicMock(return_value=instance) mocker.patch("nextcloud_mcp_server.api.webhooks.WebhooksClient", cls) return cls def _patch_outbound_client_factory(mocker) -> MagicMock: """Patch nextcloud_httpx_client so we can assert on the kwargs (esp. ``auth=``) the handler called it with.""" mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) factory = MagicMock(return_value=mock_client) mocker.patch("nextcloud_mcp_server.api.webhooks.nextcloud_httpx_client", factory) return factory def _assert_basic_auth_not_bearer(factory: MagicMock) -> None: """Outbound httpx kwargs must use ``auth=BasicAuth(...)`` and NOT a Bearer ``Authorization`` header.""" factory.assert_called_once() kwargs = factory.call_args.kwargs assert isinstance(kwargs["auth"], httpx.BasicAuth), ( "Outbound NC request must use BasicAuth, not bearer-header forwarding" ) headers = kwargs.get("headers") or {} assert "Authorization" not in headers, ( "Outbound NC request must NOT carry an Authorization header — " "the OAuth bearer must never be forwarded to Nextcloud" ) # --------------------------------------------------------------------------- # list_webhooks # --------------------------------------------------------------------------- async def test_list_webhooks_uses_basic_auth(mocker): _patch_token_validation(mocker) _patch_basic_auth(mocker, username="alice", app_password="alice-pwd") factory = _patch_outbound_client_factory(mocker) _patch_webhooks_client( mocker, list_webhooks=[{"id": 1, "event": "test", "uri": "http://x"}] ) client = TestClient(_build_test_app()) resp = client.get("/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}) assert resp.status_code == 200 assert resp.json() == {"webhooks": [{"id": 1, "event": "test", "uri": "http://x"}]} _assert_basic_auth_not_bearer(factory) async def test_list_webhooks_returns_428_when_unprovisioned(mocker): _patch_token_validation(mocker) mocker.patch( "nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user", new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")), ) client = TestClient(_build_test_app()) resp = client.get("/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}) assert resp.status_code == 428 assert resp.json()["error"] == "Provisioning required" # --------------------------------------------------------------------------- # create_webhook # --------------------------------------------------------------------------- async def test_create_webhook_uses_basic_auth(mocker): _patch_token_validation(mocker) _patch_basic_auth(mocker, username="bob", app_password="bob-pwd") factory = _patch_outbound_client_factory(mocker) _patch_webhooks_client( mocker, create_webhook={"id": 42, "event": "OCP\\Events\\NodeCreated"}, ) mocker.patch( "nextcloud_mcp_server.api.webhooks.webhook_auth_pair", return_value=("header", {"Authorization": "Bearer supersecret"}), ) client = TestClient(_build_test_app()) resp = client.post( "/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}, json={ "event": "OCP\\Events\\NodeCreated", "uri": "http://mcp:8000/webhooks/nextcloud", }, ) assert resp.status_code == 200 assert resp.json() == {"webhook": {"id": 42, "event": "OCP\\Events\\NodeCreated"}} _assert_basic_auth_not_bearer(factory) async def test_create_webhook_returns_503_when_secret_unset(mocker): """Security (GHSA-8vh3-g2qg-2h2c): registration is refused without a WEBHOOK_SECRET so no unauthenticated delivery target is created.""" _patch_token_validation(mocker) _patch_basic_auth(mocker, username="bob", app_password="bob-pwd") _patch_outbound_client_factory(mocker) mocker.patch( "nextcloud_mcp_server.api.webhooks.webhook_auth_pair", side_effect=WebhookSecretNotConfigured("WEBHOOK_SECRET must be set"), ) client = TestClient(_build_test_app()) # https example URL — registration is refused before the uri is used, and # an https literal avoids a spurious S5332 "use https" hotspot in new code. resp = client.post( "/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}, json={ "event": "OCP\\Events\\NodeCreated", "uri": "https://mcp.example.com/webhooks/nextcloud", }, ) assert resp.status_code == 503 assert resp.json()["error"] == "Webhooks disabled" async def test_create_webhook_validates_required_fields(mocker): _patch_token_validation(mocker) _patch_basic_auth(mocker) client = TestClient(_build_test_app()) resp = client.post( "/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}, json={"event": "X"}, # missing uri ) assert resp.status_code == 400 async def test_create_webhook_returns_428_when_unprovisioned(mocker): _patch_token_validation(mocker) mocker.patch( "nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user", new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")), ) client = TestClient(_build_test_app()) resp = client.post( "/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"}, json={"event": "X", "uri": "http://x"}, ) assert resp.status_code == 428 assert resp.json()["error"] == "Provisioning required" # --------------------------------------------------------------------------- # delete_webhook # --------------------------------------------------------------------------- async def test_delete_webhook_uses_basic_auth(mocker): _patch_token_validation(mocker) _patch_basic_auth(mocker, username="carol", app_password="carol-pwd") factory = _patch_outbound_client_factory(mocker) _patch_webhooks_client(mocker, delete_webhook=None) client = TestClient(_build_test_app()) resp = client.delete( "/api/v1/webhooks/99", headers={"Authorization": "Bearer mcp-token"} ) assert resp.status_code == 200 assert resp.json() == {"success": True, "message": "Webhook deleted"} _assert_basic_auth_not_bearer(factory) async def test_delete_webhook_rejects_non_integer_id(mocker): _patch_token_validation(mocker) _patch_basic_auth(mocker) client = TestClient(_build_test_app()) resp = client.delete( "/api/v1/webhooks/notanumber", headers={"Authorization": "Bearer mcp-token"}, ) assert resp.status_code == 400 async def test_delete_webhook_returns_428_when_unprovisioned(mocker): _patch_token_validation(mocker) mocker.patch( "nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user", new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")), ) client = TestClient(_build_test_app()) resp = client.delete( "/api/v1/webhooks/99", headers={"Authorization": "Bearer mcp-token"}, ) assert resp.status_code == 428 assert resp.json()["error"] == "Provisioning required" # --------------------------------------------------------------------------- # get_basic_auth_for_user helper # --------------------------------------------------------------------------- async def test_get_basic_auth_for_user_resolves_username_and_password(mocker): """Helper returns the stored Nextcloud username (not the OAuth user_id) when one was recorded at provisioning time.""" from nextcloud_mcp_server.api._auth import get_basic_auth_for_user storage = MagicMock() storage.get_app_password_with_scopes = AsyncMock( return_value={ "app_password": "encrypted-then-decrypted-pwd", "scopes": ["notes.read"], "username": "nc-username", "created_at": "2026-01-01", "updated_at": "2026-01-02", } ) mocker.patch( "nextcloud_mcp_server.api._auth.get_shared_storage", new=AsyncMock(return_value=storage), ) username, password = await get_basic_auth_for_user("idp-user-id") assert username == "nc-username" assert password == "encrypted-then-decrypted-pwd" async def test_get_basic_auth_for_user_falls_back_to_user_id(mocker): """When ``username`` is null in storage, the helper falls back to the OAuth-issued user_id.""" from nextcloud_mcp_server.api._auth import get_basic_auth_for_user storage = MagicMock() storage.get_app_password_with_scopes = AsyncMock( return_value={ "app_password": "pwd", "scopes": None, "username": None, "created_at": "2026-01-01", "updated_at": "2026-01-01", } ) mocker.patch( "nextcloud_mcp_server.api._auth.get_shared_storage", new=AsyncMock(return_value=storage), ) username, _ = await get_basic_auth_for_user("admin") assert username == "admin" async def test_get_basic_auth_for_user_raises_when_unprovisioned(mocker): from nextcloud_mcp_server.api._auth import get_basic_auth_for_user storage = MagicMock() storage.get_app_password_with_scopes = AsyncMock(return_value=None) mocker.patch( "nextcloud_mcp_server.api._auth.get_shared_storage", new=AsyncMock(return_value=storage), ) with pytest.raises(ProvisioningRequiredError): await get_basic_auth_for_user("admin")