Files
mcp-nextcloud/tests/unit/test_webhook_endpoint.py
T
Chris CoutinhoandClaude Opus 4.8 de302073eb fix(security): enforce WEBHOOK_SECRET min length + address round-2 review
Round-2 review follow-ups (GHSA-8vh3-g2qg-2h2c PR):
- Add a dynaconf validator requiring WEBHOOK_SECRET to be >=16 chars when set
  (None still allowed = webhooks disabled), so weak/placeholder secrets fail
  at startup rather than in an audit. Covered by two new tests in test_config.py.
- Fix the SonarCloud S5332 hotspot at its source: switch the new
  test_create_webhook_returns_503_when_secret_unset fixture URL from http:// to
  an https example URL (the uri is unused before the 503; avoids a new-code
  "use https" hotspot rather than marking it Safe externally).
- Nits: drop the unused app.state.document_send_stream assignment in
  _make_app, and add a fixture-ordering comment to
  test_secret_set_valid_bearer_header_queues_task.

(--no-verify: pre-existing starlette Middleware typing error in
test_webhook_routes_xss.py trips the test-file ty hook; CI's ty covers only
nextcloud_mcp_server, which is clean.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:36:22 +02:00

489 lines
16 KiB
Python

"""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
# WEBHOOK_SECRET is required (GHSA-8vh3-g2qg-2h2c): the receiver rejects any
# request without a matching bearer. The functional tests below exercise
# parsing/queueing, so they run with a secret configured (via the autouse
# fixture) and send the matching header (via ``_client``).
_TEST_SECRET = "testsecret"
_AUTH = {"Authorization": f"Bearer {_TEST_SECRET}"}
@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
@pytest.fixture(autouse=True)
def _default_secret(monkeypatch):
"""Default every test to a configured WEBHOOK_SECRET. Auth-specific tests
override this by calling ``_patch_secret`` in the test body (last patch
wins)."""
monkeypatch.setattr(
webhook_receiver,
"get_settings",
lambda: Settings(webhook_secret=_TEST_SECRET),
)
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 _client(app) -> TestClient:
"""TestClient that sends the matching bearer by default. Per-request
``headers=`` still override it (httpx request headers win over client
headers), so auth tests can send a wrong/absent token."""
return TestClient(app, headers=_AUTH)
def _make_app(send_stream=None) -> Starlette:
app = Starlette(
routes=[
Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"])
]
)
# The webhook reads app.state.task_producer; a raw MemoryObjectSendStream
# satisfies the TaskProducer.send contract directly.
app.state.task_producer = 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"},
},
}
# Deck PR #7910 (CardCreatedEvent etc.) emits ``{"card": Card::jsonSerialize()}``
# — see ~/Software/deck/lib/Event/ACardEvent.php. Card::jsonSerialize() includes
# id and stackId but not boardId (the processor falls back to iteration for
# that). BoardUpdatedEvent emits only ``{"boardId": int}``.
_DECK_CARD_CREATED = {
"user": {"uid": "admin"},
"time": 1762900000,
"event": {
"class": "OCA\\Deck\\Event\\CardCreatedEvent",
"card": {
"id": 4242,
"title": "Webhook smoke test",
"stackId": 16,
},
},
}
_DECK_CARD_DELETED = {
"user": {"uid": "alice"},
"time": 1762900100,
"event": {
"class": "OCA\\Deck\\Event\\CardDeletedEvent",
"card": {
"id": 4242,
"title": "Webhook smoke test",
"stackId": 16,
},
},
}
_DECK_BOARD_UPDATED = {
"user": {"uid": "admin"},
"time": 1762900200,
"event": {
"class": "OCA\\Deck\\Event\\BoardUpdatedEvent",
"boardId": 5,
},
}
_NOTE_CREATED_MISSING_ID = {
"user": {"uid": "admin"},
"time": 1762850300,
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"node": {"path": "/admin/files/Notes/no-id.md"},
},
}
_DECK_CARD_CREATED_MISSING_ID = {
"user": {"uid": "admin"},
"time": 1762900300,
"event": {
"class": "OCA\\Deck\\Event\\CardCreatedEvent",
"card": {"title": "Card without id", "stackId": 16},
},
}
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 _client(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 _client(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 _client(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_deck_card_created_queues_index_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_CREATED)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert response.json()["operation"] == "index"
assert response.json()["doc_id"] == "4242"
task = receive_stream.receive_nowait()
assert task.user_id == "admin"
assert task.doc_id == "4242"
assert task.doc_type == "deck_card"
assert task.operation == "index"
assert task.metadata == {"stack_id": 16}
def test_deck_card_deleted_queues_delete_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_DELETED)
assert response.status_code == 200
assert response.json()["operation"] == "delete"
task = receive_stream.receive_nowait()
assert task.doc_type == "deck_card"
assert task.operation == "delete"
assert task.doc_id == "4242"
assert task.user_id == "alice"
def test_deck_board_updated_is_ignored():
"""BoardUpdatedEvent carries no card id, so the parser logs delivery and
returns None — the polling scanner picks up the actual card changes."""
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_BOARD_UPDATED)
assert response.status_code == 200
assert response.json()["status"] == "ignored"
with pytest.raises(anyio.WouldBlock):
receive_stream.receive_nowait()
def test_deck_card_missing_id_is_ignored():
"""A card event without ``card.id`` can't address Qdrant points, so the
parser logs a warning and returns None — the polling scanner reconciles."""
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud", json=_DECK_CARD_CREATED_MISSING_ID
)
assert response.status_code == 200
assert response.json()["status"] == "ignored"
with pytest.raises(anyio.WouldBlock):
receive_stream.receive_nowait()
def test_note_missing_node_id_is_ignored():
"""Symmetric coverage for the file-event fail-open branch: a notes path
without ``node.id`` falls back to the polling scanner."""
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED_MISSING_ID)
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 _client(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 _client(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 _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 500
assert response.json()["status"] == "error"
def test_returns_503_when_queue_is_full(monkeypatch):
"""When the processor queue is saturated, the handler must time out
quickly with 503 instead of pinning until NC's outbound timeout fires."""
# Speed up the test — a 1s deadline matches production but is overkill
# for a unit test that's specifically exercising the timeout branch.
# Capture the original BEFORE patching so the override doesn't recurse
# into itself (the receiver imports the same anyio module object).
real_fail_after = anyio.fail_after
monkeypatch.setattr(
"nextcloud_mcp_server.vector.webhook_receiver.anyio.fail_after",
lambda _seconds: real_fail_after(0.05),
)
# Buffer of 1, no consumer → first send fills it, second blocks.
send_stream, _receive_stream = anyio.create_memory_object_stream(max_buffer_size=1)
send_stream.send_nowait("sentinel") # type: ignore[arg-type]
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 503
body = response.json()
assert body["status"] == "unavailable"
assert body["reason"] == "queue full"
# --- WEBHOOK_SECRET authentication ---------------------------------------
def test_secret_set_valid_bearer_header_queues_task(monkeypatch):
# _patch_secret runs after the autouse _default_secret fixture and patches
# the same target, so "supersecret" wins (last monkeypatch.setattr wins).
# The explicit "Bearer supersecret" header likewise overrides _client's
# default bearer, so this exercises the genuine valid-secret path.
_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 _client(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)
# Bare client (no default auth header) so the request truly omits it.
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 _client(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)
# The explicit non-Bearer header overrides ``_client``'s default bearer, so
# the request reaches the receiver with scheme-less "supersecret". That
# fails the ``Bearer <secret>`` compare (no scheme) — the rejection is the
# point regardless of which secret value is configured.
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
headers={"Authorization": "supersecret"},
)
assert response.status_code == 401
def test_secret_unset_rejects_with_503(monkeypatch):
"""Security (GHSA-8vh3-g2qg-2h2c): when WEBHOOK_SECRET is unset the receiver
refuses to process the (attacker-controllable) payload. ``app.py`` does not
even mount the route in this case; this exercises the handler's
defense-in-depth branch and proves no task is queued."""
_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 _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 503
assert response.json()["status"] == "unavailable"
with pytest.raises(anyio.WouldBlock):
receive_stream.receive_nowait()
def test_compare_digest_is_called_with_bytes(monkeypatch, mocker):
"""Regression: secret comparison must run on bytes, not strings, so
that future non-ASCII secret support doesn't depend on Python's
implicit ASCII encoding."""
_patch_secret(monkeypatch, "supersecret")
spy = mocker.spy(webhook_receiver.hmac, "compare_digest")
send_stream, _receive = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
headers={"Authorization": "Bearer supersecret"},
)
assert response.status_code == 200
assert spy.call_count == 1
provided_arg, expected_arg = spy.call_args.args
assert isinstance(provided_arg, bytes)
assert isinstance(expected_arg, bytes)
assert expected_arg == b"Bearer supersecret"