"""Unit tests for the ``/webhooks/nextcloud`` HTTP receiver. Builds a minimal Starlette app around ``handle_nextcloud_webhook`` so we can drive it with ``TestClient`` without standing up the full FastMCP server. """ import anyio import pytest from starlette.applications import Starlette from starlette.routing import Route from starlette.testclient import TestClient from nextcloud_mcp_server.config import Settings from nextcloud_mcp_server.vector import webhook_receiver from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhook pytestmark = pytest.mark.unit @pytest.fixture(autouse=True) def _reset_warned_flag(): """The receiver warns once per process when WEBHOOK_SECRET is missing. Reset between tests so each gets a clean slate.""" webhook_receiver._warned_about_missing_secret = False yield webhook_receiver._warned_about_missing_secret = False def _patch_secret(monkeypatch, secret: str | None) -> None: """Make ``get_settings()`` (as called inside the receiver) return a Settings instance with the given ``webhook_secret``.""" monkeypatch.setattr( webhook_receiver, "get_settings", lambda: Settings(webhook_secret=secret), ) def _make_app(send_stream=None) -> Starlette: app = Starlette( routes=[ Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"]) ] ) app.state.document_send_stream = send_stream return app _NOTE_CREATED = { "user": {"uid": "admin", "displayName": "admin"}, "time": 1762850245, "event": { "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", "node": { "id": 437, "path": "/admin/files/Notes/Webhooks/Webhook Test Note.md", }, }, } _NOTE_DELETED = { "user": {"uid": "alice"}, "time": 1762851093, "event": { "class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent", "node": {"id": 99, "path": "/alice/files/Notes/foo.md"}, }, } def test_index_event_queues_task_and_returns_200(): send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED) assert response.status_code == 200 assert response.json()["status"] == "queued" assert response.json()["operation"] == "index" assert response.json()["doc_id"] == "437" task = receive_stream.receive_nowait() assert task.user_id == "admin" assert task.doc_id == "437" assert task.operation == "index" assert task.doc_type == "note" def test_delete_event_queues_delete_task(): send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_DELETED) assert response.status_code == 200 assert response.json()["operation"] == "delete" task = receive_stream.receive_nowait() assert task.operation == "delete" assert task.doc_id == "99" assert task.user_id == "alice" def test_unsupported_event_is_ignored(): send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) payload = { "user": {"uid": "admin"}, "time": 1, "event": { "class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent", "objectData": {"id": 7}, }, } with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=payload) assert response.status_code == 200 assert response.json()["status"] == "ignored" with pytest.raises(anyio.WouldBlock): receive_stream.receive_nowait() def test_invalid_json_returns_400(): app = _make_app(send_stream=None) with TestClient(app) as client: response = client.post( "/webhooks/nextcloud", content=b"not json", headers={"content-type": "application/json"}, ) assert response.status_code == 400 assert response.json()["status"] == "error" def test_returns_503_when_send_stream_not_wired(): """Vector sync not running → tell NC to retry instead of dropping the event.""" app = _make_app(send_stream=None) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED) assert response.status_code == 503 assert response.json()["status"] == "unavailable" def test_returns_500_when_stream_is_closed(): send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=1) receive_stream.close() # close receiver → send raises BrokenResourceError app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED) assert response.status_code == 500 assert response.json()["status"] == "error" # --- WEBHOOK_SECRET authentication --------------------------------------- def test_secret_set_valid_bearer_header_queues_task(monkeypatch): _patch_secret(monkeypatch, "supersecret") send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post( "/webhooks/nextcloud", json=_NOTE_CREATED, headers={"Authorization": "Bearer supersecret"}, ) assert response.status_code == 200 assert response.json()["status"] == "queued" assert receive_stream.receive_nowait().doc_id == "437" def test_secret_set_missing_authorization_returns_401(monkeypatch): _patch_secret(monkeypatch, "supersecret") send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED) assert response.status_code == 401 assert response.json()["status"] == "unauthorized" with pytest.raises(anyio.WouldBlock): receive_stream.receive_nowait() def test_secret_set_wrong_secret_returns_401(monkeypatch): _patch_secret(monkeypatch, "supersecret") send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post( "/webhooks/nextcloud", json=_NOTE_CREATED, headers={"Authorization": "Bearer wrong"}, ) assert response.status_code == 401 with pytest.raises(anyio.WouldBlock): receive_stream.receive_nowait() def test_secret_set_wrong_scheme_returns_401(monkeypatch): """A token without the Bearer prefix is rejected.""" _patch_secret(monkeypatch, "supersecret") send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post( "/webhooks/nextcloud", json=_NOTE_CREATED, headers={"Authorization": "supersecret"}, ) assert response.status_code == 401 def test_secret_unset_accepts_unauthenticated(monkeypatch): """Backward compat: deployments that haven't yet set WEBHOOK_SECRET keep working — the receiver accepts unauthenticated POSTs and logs a one-time warning.""" _patch_secret(monkeypatch, None) send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4) app = _make_app(send_stream=send_stream) with TestClient(app) as client: response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED) assert response.status_code == 200 assert receive_stream.receive_nowait().doc_id == "437"