From 2e2a098bee62a2e0e1bb2fc61f45c4339240f9d4 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 30 Apr 2026 03:13:50 +0200 Subject: [PATCH] fix(webhooks): wire receiver to vector sync queue and fix registered URI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /webhooks/nextcloud endpoint was a no-op stub that logged the payload and returned 200 OK; webhook deletions never reached Qdrant. Compounding that, _get_webhook_uri() registered the docker-compose internal hostname (http://mcp:8000) with Nextcloud whenever /.dockerenv existed โ€” including ECS Fargate โ€” so cloud deployments were registering a URL NC could not resolve. - New vector/webhook_parser.py extracts a DocumentTask from NodeCreatedEvent / NodeWrittenEvent / BeforeNodeDeletedEvent payloads scoped to */files/Notes/*.md (matching the registered preset filters). - New vector/webhook_receiver.py pushes that task onto the same send-stream the scanner uses (app.state.document_send_stream), with 503 when sync is not running so NC retries delivery. - _get_webhook_uri() now prefers NEXTCLOUD_MCP_SERVER_URL over the /.dockerenv branch, so the explicit public URL set on cloud tasks wins; docker-compose dev still falls back to the internal name when no public URL is configured. Calendar / Tables event parsing is intentionally out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/app.py | 31 +-- nextcloud_mcp_server/auth/webhook_routes.py | 46 +++-- nextcloud_mcp_server/vector/webhook_parser.py | 86 ++++++++ .../vector/webhook_receiver.py | 82 ++++++++ tests/unit/test_webhook_endpoint.py | 144 ++++++++++++++ tests/unit/test_webhook_parser.py | 188 ++++++++++++++++++ tests/unit/test_webhook_uri.py | 95 +++++++++ 7 files changed, 624 insertions(+), 48 deletions(-) create mode 100644 nextcloud_mcp_server/vector/webhook_parser.py create mode 100644 nextcloud_mcp_server/vector/webhook_receiver.py create mode 100644 tests/unit/test_webhook_endpoint.py create mode 100644 tests/unit/test_webhook_parser.py create mode 100644 tests/unit/test_webhook_uri.py diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index e0b1c87a..f5e72660 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -130,6 +130,7 @@ from nextcloud_mcp_server.vector.oauth_sync import ( user_manager_task, ) from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client +from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhook logger = logging.getLogger(__name__) HTTPXClientInstrumentor().instrument() @@ -1950,30 +1951,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = status_code=status_code, ) - async def handle_nextcloud_webhook(request): - """Test webhook endpoint to capture and log Nextcloud webhook payloads. - - This is a temporary endpoint for testing webhook schemas and payloads. - It logs the full payload and returns 200 OK immediately. - """ - - try: - payload = await request.json() - logger.info("=" * 80) - logger.info("๐Ÿ”” Webhook received from Nextcloud:") - logger.info(json.dumps(payload, indent=2, sort_keys=True)) - logger.info("=" * 80) - - return JSONResponse( - {"status": "received", "timestamp": payload.get("time")}, - status_code=200, - ) - except Exception as e: - logger.error(f"โŒ Failed to parse webhook payload: {e}") - return JSONResponse( - {"error": "invalid_payload", "message": str(e)}, status_code=400 - ) - # Add Protected Resource Metadata (PRM) endpoint for OAuth mode routes = [] @@ -1982,11 +1959,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = routes.append(Route("/health/ready", health_ready, methods=["GET"])) logger.info("Health check endpoints enabled: /health/live, /health/ready") - # Add test webhook endpoint (for development/testing) + # Add Nextcloud webhook receiver (queues DocumentTasks for vector sync). + # Implementation lives in vector/webhook_receiver.py; the handler reads + # the send-stream from request.app.state.document_send_stream. routes.append( Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"]) ) - logger.info("Test webhook endpoint enabled: /webhooks/nextcloud") + logger.info("Webhook endpoint enabled: /webhooks/nextcloud") # Add management API endpoints for Nextcloud PHP app # Tier 1: Public endpoints (no auth required) diff --git a/nextcloud_mcp_server/auth/webhook_routes.py b/nextcloud_mcp_server/auth/webhook_routes.py index e7ee3c66..7606e2ea 100644 --- a/nextcloud_mcp_server/auth/webhook_routes.py +++ b/nextcloud_mcp_server/auth/webhook_routes.py @@ -77,33 +77,37 @@ async def _get_installed_apps(http_client: httpx.AsyncClient) -> list[str]: def _get_webhook_uri() -> str: """Get the webhook endpoint URI for this MCP server. - This function determines the correct webhook URL based on the environment: - 1. Uses WEBHOOK_INTERNAL_URL if explicitly set (highest priority) - 2. Detects Docker environment and uses internal service name - 3. Falls back to NEXTCLOUD_MCP_SERVER_URL + Priority (highest first): + 1. ``WEBHOOK_INTERNAL_URL`` โ€” explicit override, e.g. for split + internal/external URLs. + 2. ``NEXTCLOUD_MCP_SERVER_URL`` โ€” the configured public URL. This is + set on cloud deployments (ECS, k8s) and is the URL Nextcloud must + POST to. + 3. ``/.dockerenv`` (or podman / ``DOCKER_CONTAINER=true``) โ†’ internal + docker-compose service name. Only relevant when no public URL is + configured โ€” i.e. local dev where MCP and NC share a Docker + network. + 4. ``http://localhost:8000`` โ€” last-resort fallback. - In Docker environments, Nextcloud needs to reach the MCP service using - the internal Docker network hostname (e.g., http://mcp:8000), not localhost. - - Returns: - Full webhook endpoint URL accessible from Nextcloud + Note: ECS Fargate containers also expose ``/.dockerenv``. Without this + priority order, cloud deployments would silently register an internal + docker-compose hostname (e.g. ``http://mcp:8000``) that NC cannot + resolve, dropping every webhook delivery. """ - # Explicit override (highest priority) webhook_url = os.getenv("WEBHOOK_INTERNAL_URL") if webhook_url: return f"{webhook_url}/webhooks/nextcloud" - # Detect Docker environment - # Check for common Docker indicators - is_docker = ( - os.path.exists("/.dockerenv") # Docker container marker file - or os.path.exists("/run/.containerenv") # Podman marker - or os.getenv("DOCKER_CONTAINER") == "true" # Explicit flag - ) + server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL") + if server_url: + return f"{server_url}/webhooks/nextcloud" + is_docker = ( + os.path.exists("/.dockerenv") + or os.path.exists("/run/.containerenv") + or os.getenv("DOCKER_CONTAINER") == "true" + ) if is_docker: - # In Docker, use internal service name from NEXTCLOUD_MCP_SERVICE_NAME - # or default to 'mcp' (docker-compose service name) service_name = os.getenv("NEXTCLOUD_MCP_SERVICE_NAME", "mcp") port = os.getenv("NEXTCLOUD_MCP_PORT", "8000") logger.debug( @@ -111,9 +115,7 @@ def _get_webhook_uri() -> str: ) return f"http://{service_name}:{port}/webhooks/nextcloud" - # Fallback to configured server URL (for non-Docker deployments) - server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") - return f"{server_url}/webhooks/nextcloud" + return "http://localhost:8000/webhooks/nextcloud" async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: diff --git a/nextcloud_mcp_server/vector/webhook_parser.py b/nextcloud_mcp_server/vector/webhook_parser.py new file mode 100644 index 00000000..82297729 --- /dev/null +++ b/nextcloud_mcp_server/vector/webhook_parser.py @@ -0,0 +1,86 @@ +"""Parse Nextcloud webhook payloads into DocumentTask objects. + +Maps Nextcloud webhook events to vector-sync DocumentTasks. The handler at +``/webhooks/nextcloud`` calls :func:`extract_document_task` and forwards any +non-None result to the same processor send-stream the scanner uses. + +Currently scoped to file (note) events. Calendar / Tables events fall through +to ``None`` for now; those parsers can be added in follow-up changes. + +See ADR-010 for the design and ``webhook-testing-findings.md`` for real +captured payloads. +""" + +import logging +import re + +from nextcloud_mcp_server.vector.scanner import DocumentTask + +logger = logging.getLogger(__name__) + +_FILE_EVENT_CREATED = "OCP\\Files\\Events\\Node\\NodeCreatedEvent" +_FILE_EVENT_WRITTEN = "OCP\\Files\\Events\\Node\\NodeWrittenEvent" +_FILE_EVENT_BEFORE_DELETED = "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent" + +# Matches paths inside any user's Notes folder ending in .md, e.g. +# "/admin/files/Notes/Sub/Note.md" or "/alice/files/Notes/foo.md". +_NOTES_PATH_RE = re.compile(r"^/[^/]+/files/Notes/.+\.md$") + + +def extract_document_task(payload: dict) -> DocumentTask | None: + """Convert a Nextcloud webhook payload into a DocumentTask. + + Returns None for any event we don't (yet) handle, or any event whose + target isn't a markdown file under a user's Notes folder. Callers should + treat None as "ignored" โ€” not an error. + """ + try: + event = payload["event"] + event_class = event["class"] + user_id = payload["user"]["uid"] + except (KeyError, TypeError): + logger.debug("Webhook payload missing user/event/class fields") + return None + + if event_class in ( + _FILE_EVENT_CREATED, + _FILE_EVENT_WRITTEN, + _FILE_EVENT_BEFORE_DELETED, + ): + return _parse_file_event(event_class, event, user_id, payload.get("time", 0)) + + logger.debug("Ignoring webhook for unsupported event: %s", event_class) + return None + + +def _parse_file_event( + event_class: str, event: dict, user_id: str, time: int +) -> DocumentTask | None: + node = event.get("node") or {} + path = node.get("path", "") + node_id = node.get("id") + + if not _NOTES_PATH_RE.match(path): + # Not a note file โ€” could be a parent folder, an unrelated file, etc. + return None + + if node_id is None: + # BeforeNodeDeletedEvent should still carry node.id; if it doesn't + # we can't address the Qdrant points to delete. Skip rather than + # guess โ€” the polling scanner will catch up via its grace period. + logger.warning( + "Webhook %s for note %s missing node.id; falling back to scanner", + event_class, + path, + ) + return None + + operation = "delete" if event_class == _FILE_EVENT_BEFORE_DELETED else "index" + + return DocumentTask( + user_id=user_id, + doc_id=str(node_id), + doc_type="note", + operation=operation, + modified_at=int(time), + ) diff --git a/nextcloud_mcp_server/vector/webhook_receiver.py b/nextcloud_mcp_server/vector/webhook_receiver.py new file mode 100644 index 00000000..0086eb5d --- /dev/null +++ b/nextcloud_mcp_server/vector/webhook_receiver.py @@ -0,0 +1,82 @@ +"""HTTP receiver for Nextcloud webhooks. + +Routes inbound webhooks to the same processor send-stream the scanner uses. +The receiver is registered as a Starlette route at ``/webhooks/nextcloud`` +in :mod:`nextcloud_mcp_server.app`. +""" + +import logging + +from starlette.requests import Request +from starlette.responses import JSONResponse + +from nextcloud_mcp_server.vector.webhook_parser import extract_document_task + +logger = logging.getLogger(__name__) + + +async def handle_nextcloud_webhook(request: Request) -> JSONResponse: + """Receive a Nextcloud webhook and queue a DocumentTask for vector sync. + + Returns quickly so NC's webhook worker is not blocked. The send-stream is + read from ``request.app.state.document_send_stream``; when vector sync + isn't running we return 503 so NC retries delivery. + """ + try: + payload = await request.json() + except Exception as e: + logger.warning(f"Webhook payload was not valid JSON: {e}") + return JSONResponse( + {"status": "error", "message": "invalid JSON"}, + status_code=400, + ) + + task = extract_document_task(payload) + if task is None: + event_class = (payload.get("event") or {}).get("class", "") + logger.debug("Webhook ignored (unsupported event): %s", event_class) + return JSONResponse( + {"status": "ignored", "reason": "unsupported event"}, + status_code=200, + ) + + send_stream = getattr(request.app.state, "document_send_stream", None) + if send_stream is None: + logger.warning( + "Webhook received but vector sync is not running; rejecting so NC retries" + ) + return JSONResponse( + {"status": "unavailable", "reason": "vector sync not running"}, + status_code=503, + ) + + try: + await send_stream.send(task) + except Exception as e: + logger.error( + "Failed to queue webhook task for %s_%s: %s", + task.doc_type, + task.doc_id, + e, + ) + return JSONResponse( + {"status": "error", "message": "queue unavailable"}, + status_code=500, + ) + + logger.info( + "Webhook queued %s_%s (%s) for user %s", + task.doc_type, + task.doc_id, + task.operation, + task.user_id, + ) + return JSONResponse( + { + "status": "queued", + "doc_type": task.doc_type, + "doc_id": task.doc_id, + "operation": task.operation, + }, + status_code=200, + ) diff --git a/tests/unit/test_webhook_endpoint.py b/tests/unit/test_webhook_endpoint.py new file mode 100644 index 00000000..6ae7c1bd --- /dev/null +++ b/tests/unit/test_webhook_endpoint.py @@ -0,0 +1,144 @@ +"""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.vector.webhook_receiver import handle_nextcloud_webhook + +pytestmark = pytest.mark.unit + + +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" diff --git a/tests/unit/test_webhook_parser.py b/tests/unit/test_webhook_parser.py new file mode 100644 index 00000000..0f044915 --- /dev/null +++ b/tests/unit/test_webhook_parser.py @@ -0,0 +1,188 @@ +"""Unit tests for the Nextcloud webhook payload parser. + +Payload examples are taken from real Nextcloud captures recorded in +``webhook-testing-findings.md``. +""" + +import pytest + +from nextcloud_mcp_server.vector.webhook_parser import extract_document_task + + +@pytest.mark.unit +def test_node_created_event_returns_index_task(): + payload = { + "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", + }, + }, + } + + task = extract_document_task(payload) + + assert task is not None + assert task.user_id == "admin" + assert task.doc_id == "437" + assert task.doc_type == "note" + assert task.operation == "index" + assert task.modified_at == 1762850245 + + +@pytest.mark.unit +def test_node_written_event_returns_index_task(): + payload = { + "user": {"uid": "admin", "displayName": "admin"}, + "time": 1762850960, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeWrittenEvent", + "node": { + "id": 437, + "path": "/admin/files/Notes/Webhooks/Webhook Test Note.md", + }, + }, + } + + task = extract_document_task(payload) + + assert task is not None + assert task.operation == "index" + assert task.doc_id == "437" + + +@pytest.mark.unit +def test_before_node_deleted_event_returns_delete_task(): + payload = { + "user": {"uid": "alice", "displayName": "Alice"}, + "time": 1762851093, + "event": { + "class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent", + "node": { + "id": 437, + "path": "/alice/files/Notes/Webhooks/Webhook Test Note.md", + }, + }, + } + + task = extract_document_task(payload) + + assert task is not None + assert task.user_id == "alice" + assert task.operation == "delete" + assert task.doc_id == "437" + assert task.doc_type == "note" + + +@pytest.mark.unit +def test_node_id_is_normalized_to_string(): + """NC sends node.id as int; we always emit str (per ADR-010 ยงA.3).""" + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"id": 437, "path": "/admin/files/Notes/foo.md"}, + }, + } + + task = extract_document_task(payload) + + assert task is not None + assert isinstance(task.doc_id, str) + assert task.doc_id == "437" + + +@pytest.mark.unit +def test_path_outside_notes_returns_none(): + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"id": 1, "path": "/admin/files/Documents/foo.md"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_non_markdown_inside_notes_returns_none(): + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"id": 1, "path": "/admin/files/Notes/image.png"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_parent_folder_event_returns_none(): + """Creating a note fires events for the parent folder too โ€” ignore those.""" + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"id": 100, "path": "/admin/files/Notes/Webhooks"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_unknown_event_class_returns_none(): + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent", + "objectData": {"id": 7, "uri": "x.ics"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_node_deleted_event_without_id_returns_none(): + """``NodeDeletedEvent`` (no node.id) is not the registered event, but if + one ever leaks through we ignore it rather than guess at the doc_id โ€” + the polling scanner will catch up via its grace period.""" + payload = { + "user": {"uid": "admin"}, + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent", + "node": {"path": "/admin/files/Notes/foo.md"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_missing_user_field_returns_none(): + payload = { + "time": 1, + "event": { + "class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent", + "node": {"id": 1, "path": "/admin/files/Notes/foo.md"}, + }, + } + + assert extract_document_task(payload) is None + + +@pytest.mark.unit +def test_empty_payload_returns_none(): + assert extract_document_task({}) is None diff --git a/tests/unit/test_webhook_uri.py b/tests/unit/test_webhook_uri.py new file mode 100644 index 00000000..1d6bb18e --- /dev/null +++ b/tests/unit/test_webhook_uri.py @@ -0,0 +1,95 @@ +"""Unit tests for ``_get_webhook_uri`` priority order. + +Cloud deployments register the webhook URI returned by this function with +Nextcloud. ECS Fargate also exposes ``/.dockerenv``, so an explicit public +URL must win over the docker auto-detection branch. +""" + +import pytest + +from nextcloud_mcp_server.auth.webhook_routes import _get_webhook_uri + +ENV_VARS = ( + "WEBHOOK_INTERNAL_URL", + "NEXTCLOUD_MCP_SERVER_URL", + "NEXTCLOUD_MCP_SERVICE_NAME", + "NEXTCLOUD_MCP_PORT", + "DOCKER_CONTAINER", +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for name in ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _no_docker_markers(monkeypatch): + monkeypatch.setattr( + "nextcloud_mcp_server.auth.webhook_routes.os.path.exists", + lambda _path: False, + ) + + +def _docker_markers(monkeypatch): + monkeypatch.setattr( + "nextcloud_mcp_server.auth.webhook_routes.os.path.exists", + lambda path: path == "/.dockerenv", + ) + + +@pytest.mark.unit +def test_webhook_internal_url_wins_over_everything(monkeypatch): + monkeypatch.setenv("WEBHOOK_INTERNAL_URL", "https://internal.example.com") + monkeypatch.setenv("NEXTCLOUD_MCP_SERVER_URL", "https://public.example.com") + _docker_markers(monkeypatch) + + assert _get_webhook_uri() == "https://internal.example.com/webhooks/nextcloud" + + +@pytest.mark.unit +def test_public_url_wins_over_docker_detection(monkeypatch): + """The bug-fix case: ECS containers have /.dockerenv but a public URL is + set. Docker auto-detection must NOT clobber the explicit public URL.""" + monkeypatch.setenv( + "NEXTCLOUD_MCP_SERVER_URL", "https://holy-bluegill.astrolabecloud.com" + ) + _docker_markers(monkeypatch) + + assert ( + _get_webhook_uri() + == "https://holy-bluegill.astrolabecloud.com/webhooks/nextcloud" + ) + + +@pytest.mark.unit +def test_docker_detection_used_when_no_public_url(monkeypatch): + """docker-compose dev: no public URL set, /.dockerenv exists โ†’ use the + docker-compose service name.""" + _docker_markers(monkeypatch) + + assert _get_webhook_uri() == "http://mcp:8000/webhooks/nextcloud" + + +@pytest.mark.unit +def test_docker_detection_honors_service_name_and_port_overrides(monkeypatch): + monkeypatch.setenv("NEXTCLOUD_MCP_SERVICE_NAME", "mcp-login-flow") + monkeypatch.setenv("NEXTCLOUD_MCP_PORT", "8004") + _docker_markers(monkeypatch) + + assert _get_webhook_uri() == "http://mcp-login-flow:8004/webhooks/nextcloud" + + +@pytest.mark.unit +def test_docker_container_env_var_triggers_docker_branch(monkeypatch): + monkeypatch.setenv("DOCKER_CONTAINER", "true") + _no_docker_markers(monkeypatch) + + assert _get_webhook_uri() == "http://mcp:8000/webhooks/nextcloud" + + +@pytest.mark.unit +def test_localhost_fallback_when_nothing_set(monkeypatch): + _no_docker_markers(monkeypatch) + + assert _get_webhook_uri() == "http://localhost:8000/webhooks/nextcloud"