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>
253 lines
8.0 KiB
Python
253 lines
8.0 KiB
Python
"""Unit tests for WebhooksClient."""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
|
|
|
|
@pytest.fixture
|
|
def webhooks_client(mocker):
|
|
"""Create a WebhooksClient with mocked HTTP client."""
|
|
mock_http_client = mocker.AsyncMock(spec=AsyncClient)
|
|
return WebhooksClient(mock_http_client, "testuser")
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_list_webhooks(webhooks_client, mocker):
|
|
"""Test listing registered webhooks."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": [
|
|
{
|
|
"id": 1,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"httpMethod": "POST",
|
|
},
|
|
{
|
|
"id": 2,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeWrittenEvent",
|
|
"httpMethod": "POST",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
webhooks = await webhooks_client.list_webhooks()
|
|
|
|
assert len(webhooks) == 2
|
|
assert webhooks[0]["id"] == 1
|
|
assert webhooks[0]["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
|
assert webhooks[1]["id"] == 2
|
|
|
|
mock_make_request.assert_called_once_with(
|
|
"GET",
|
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
|
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_list_webhooks_empty(webhooks_client, mocker):
|
|
"""Test listing webhooks when none are registered."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {"ocs": {"data": []}}
|
|
|
|
mocker.patch.object(WebhooksClient, "_make_request", return_value=mock_response)
|
|
|
|
webhooks = await webhooks_client.list_webhooks()
|
|
|
|
assert webhooks == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_webhook(webhooks_client, mocker):
|
|
"""Test creating a webhook registration."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": {
|
|
"id": 123,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"httpMethod": "POST",
|
|
"authMethod": "none",
|
|
}
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
webhook_data = await webhooks_client.create_webhook(
|
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
uri="http://example.com/webhook",
|
|
)
|
|
|
|
assert webhook_data["id"] == 123
|
|
assert webhook_data["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
|
|
|
mock_make_request.assert_called_once()
|
|
call_args = mock_make_request.call_args
|
|
assert call_args[0][0] == "POST"
|
|
assert call_args[0][1] == "/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks"
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_webhook_with_filter(webhooks_client, mocker):
|
|
"""Test creating a webhook with event filter."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": {
|
|
"id": 124,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"eventFilter": {"user.uid": "bob"},
|
|
}
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
webhook_data = await webhooks_client.create_webhook(
|
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
uri="http://example.com/webhook",
|
|
event_filter={"user.uid": "bob"},
|
|
)
|
|
|
|
assert webhook_data["id"] == 124
|
|
assert webhook_data["eventFilter"] == {"user.uid": "bob"}
|
|
|
|
mock_make_request.assert_called_once()
|
|
call_args = mock_make_request.call_args
|
|
assert call_args[1]["json"]["eventFilter"] == {"user.uid": "bob"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
|
|
"""Test creating a webhook with authentication headers."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": {
|
|
"id": 125,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"authMethod": "bearer",
|
|
}
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
webhook_data = await webhooks_client.create_webhook(
|
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
uri="http://example.com/webhook",
|
|
auth_method="bearer",
|
|
headers={"Authorization": "Bearer secret-token"},
|
|
)
|
|
|
|
assert webhook_data["id"] == 125
|
|
assert webhook_data["authMethod"] == "bearer"
|
|
|
|
mock_make_request.assert_called_once()
|
|
call_args = mock_make_request.call_args
|
|
assert call_args[1]["json"]["authMethod"] == "bearer"
|
|
assert call_args[1]["json"]["headers"] == {"Authorization": "Bearer secret-token"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_create_webhook_with_auth_data(webhooks_client, mocker):
|
|
"""``auth_data`` lands in the OCS body as ``authData`` so NC encrypts
|
|
the credentials at-rest and merges them in at delivery time."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": {
|
|
"id": 126,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"authMethod": "header",
|
|
}
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
await webhooks_client.create_webhook(
|
|
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
uri="http://example.com/webhook",
|
|
auth_method="header",
|
|
auth_data={"Authorization": "Bearer supersecret"},
|
|
)
|
|
|
|
call_args = mock_make_request.call_args
|
|
assert call_args[1]["json"]["authMethod"] == "header"
|
|
assert call_args[1]["json"]["authData"] == {"Authorization": "Bearer supersecret"}
|
|
# The static `headers` field must NOT be set when only auth_data is passed.
|
|
assert "headers" not in call_args[1]["json"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_delete_webhook(webhooks_client, mocker):
|
|
"""Test deleting a webhook registration."""
|
|
mock_response = mocker.Mock()
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
await webhooks_client.delete_webhook(webhook_id=123)
|
|
|
|
mock_make_request.assert_called_once_with(
|
|
"DELETE",
|
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/123",
|
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
async def test_get_webhook(webhooks_client, mocker):
|
|
"""Test getting a specific webhook by ID."""
|
|
mock_response = mocker.Mock()
|
|
mock_response.json.return_value = {
|
|
"ocs": {
|
|
"data": {
|
|
"id": 123,
|
|
"uri": "http://example.com/webhook",
|
|
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"httpMethod": "POST",
|
|
}
|
|
}
|
|
}
|
|
|
|
mock_make_request = mocker.patch.object(
|
|
WebhooksClient, "_make_request", return_value=mock_response
|
|
)
|
|
|
|
webhook_data = await webhooks_client.get_webhook(webhook_id=123)
|
|
|
|
assert webhook_data["id"] == 123
|
|
assert webhook_data["event"] == "OCP\\Files\\Events\\Node\\NodeCreatedEvent"
|
|
|
|
mock_make_request.assert_called_once_with(
|
|
"GET",
|
|
"/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/123",
|
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
|
)
|