Files
mcp-nextcloud/tests/unit/test_webhook_parser.py
T
Chris CoutinhoandClaude Opus 4.7 224428fca5 fix(webhooks): authenticate deliveries via WEBHOOK_SECRET; review nits
Adds optional shared-secret authentication for /webhooks/nextcloud,
addressing the security follow-up flagged in #747.

Behavior:
- WEBHOOK_SECRET set: registrations pass authMethod="header" with
  authData={"Authorization": "Bearer <secret>"} (encrypted at-rest in
  Nextcloud's DB and forwarded on every delivery). The receiver
  validates the same header with hmac.compare_digest before parsing
  any payload; missing/invalid → 401.
- WEBHOOK_SECRET unset: registrations stay on authMethod="none" and
  the receiver accepts unauthenticated POSTs (logging a one-time
  startup warning). Backward compatible — operators can roll out at
  their own pace.

Implementation notes:
- WebhooksClient.create_webhook gains an `auth_data` parameter mapped
  to NC's `authData` body field; this is distinct from the existing
  `headers` parameter (`headers` is plaintext static request headers,
  `authData` is encrypted at-rest in NC and only emitted when
  authMethod="header"). The previous `auth_method="bearer"` mention in
  the docstring was incorrect — NC supports only "none" and "header".
- A small `webhook_auth_pair()` helper in auth/webhook_routes.py
  centralises the secret→(auth_method, auth_data) resolution so the
  preset flow and the Astrolabe-facing /api/v1/webhooks endpoint stay
  in sync.

Also addresses the smaller review points from #747:
- f-string → lazy %s formatting in webhook_receiver.py and
  webhook_routes.py.
- Move `int(time)` inside webhook_parser's try/except so a malformed
  `time` field returns None instead of raising ValueError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:50:28 +02:00

219 lines
5.9 KiB
Python

"""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
@pytest.mark.unit
def test_non_numeric_time_field_returns_none():
"""A malformed ``time`` field must not raise ValueError out of the parser."""
payload = {
"user": {"uid": "admin"},
"time": "not-a-number",
"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_missing_time_field_defaults_to_zero():
payload = {
"user": {"uid": "admin"},
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"node": {"id": 1, "path": "/admin/files/Notes/foo.md"},
},
}
task = extract_document_task(payload)
assert task is not None
assert task.modified_at == 0