refactor(webhooks): address PR review on auth-pass

- webhook_receiver: always run hmac.compare_digest (drop the
  `not provided or` short-circuit) so the constant-time path is
  taken regardless of whether the Authorization header is present.
- client/webhooks: modernise the new `auth_data` type hint to
  `dict[str, str] | None` per CLAUDE.md.
- tests/client: rename `test_create_webhook_with_auth_headers` →
  `test_create_webhook_with_static_headers` and use
  `auth_method="header"` (NC's webhook_listeners only supports
  "none" and "header"; the previous "bearer" value was invalid).
- auth/webhook_routes: extract `_register_preset_webhooks` from
  `enable_webhook_preset` so the auth-threading behaviour is
  testable without standing up a Starlette app + auth middleware.
- tests/unit: new test_webhook_routes_register covering the helper
  with secret set / unset, and verifying ids round-trip in order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-30 04:02:26 +02:00
co-authored by Claude Opus 4.7
parent 224428fca5
commit f5f05b7c84
5 changed files with 136 additions and 24 deletions
+11 -8
View File
@@ -135,8 +135,11 @@ async def test_create_webhook_with_filter(webhooks_client, mocker):
@pytest.mark.unit
async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
"""Test creating a webhook with authentication headers."""
async def test_create_webhook_with_static_headers(webhooks_client, mocker):
"""Static request headers (the ``headers`` field) ride on every delivery
independently of ``authData``. NC's webhook_listeners app accepts only
``authMethod="none"`` or ``"header"`` — pre-existing tests previously
referenced ``"bearer"`` which is not a valid value."""
mock_response = mocker.Mock()
mock_response.json.return_value = {
"ocs": {
@@ -144,7 +147,7 @@ async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
"id": 125,
"uri": "http://example.com/webhook",
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"authMethod": "bearer",
"authMethod": "header",
}
}
}
@@ -156,17 +159,17 @@ async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
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"},
auth_method="header",
headers={"X-Trace-Id": "trace-123"},
)
assert webhook_data["id"] == 125
assert webhook_data["authMethod"] == "bearer"
assert webhook_data["authMethod"] == "header"
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"}
assert call_args[1]["json"]["authMethod"] == "header"
assert call_args[1]["json"]["headers"] == {"X-Trace-Id": "trace-123"}
@pytest.mark.unit
@@ -0,0 +1,84 @@
"""Unit tests for ``_register_preset_webhooks``.
The helper threads ``webhook_auth_pair()`` into each ``create_webhook``
call, so it is the integration point between the secret-resolution logic
and the OCS client. These tests verify the wiring without standing up a
full Starlette app.
"""
import pytest
from nextcloud_mcp_server.auth import webhook_routes
from nextcloud_mcp_server.auth.webhook_routes import _register_preset_webhooks
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from nextcloud_mcp_server.config import Settings
from nextcloud_mcp_server.server.webhook_presets import get_preset
pytestmark = pytest.mark.unit
def _patch_secret(monkeypatch, secret: str | None) -> None:
monkeypatch.setattr(
webhook_routes,
"get_settings",
lambda: Settings(webhook_secret=secret),
)
def _make_webhooks_client(mocker, ids: list[int]):
"""Mock WebhooksClient.create_webhook to return one fake webhook per id."""
client = mocker.AsyncMock(spec=WebhooksClient)
client.create_webhook.side_effect = [{"id": i} for i in ids]
return client
async def test_register_threads_bearer_auth_when_secret_set(monkeypatch, mocker):
_patch_secret(monkeypatch, "supersecret")
preset = get_preset("notes_sync")
assert preset is not None
client = _make_webhooks_client(mocker, ids=[101, 102, 103])
registered = await _register_preset_webhooks(
client, preset, "https://mcp.example.com/webhooks/nextcloud"
)
assert registered == [101, 102, 103]
assert client.create_webhook.await_count == len(preset["events"])
expected_auth = {"Authorization": "Bearer supersecret"}
for call, event_config in zip(
client.create_webhook.await_args_list, preset["events"]
):
kwargs = call.kwargs
assert kwargs["event"] == event_config["event"]
assert kwargs["uri"] == "https://mcp.example.com/webhooks/nextcloud"
assert kwargs["auth_method"] == "header"
assert kwargs["auth_data"] == expected_auth
# notes_sync uses path filters; ensure they round-trip through the helper
assert kwargs["event_filter"] == event_config["filter"]
async def test_register_uses_none_auth_when_secret_unset(monkeypatch, mocker):
_patch_secret(monkeypatch, None)
preset = get_preset("notes_sync")
assert preset is not None
client = _make_webhooks_client(mocker, ids=[1, 2, 3])
await _register_preset_webhooks(
client, preset, "https://mcp.example.com/webhooks/nextcloud"
)
for call in client.create_webhook.await_args_list:
assert call.kwargs["auth_method"] == "none"
assert call.kwargs["auth_data"] is None
async def test_register_returns_ids_in_call_order(monkeypatch, mocker):
_patch_secret(monkeypatch, None)
preset = get_preset("notes_sync")
assert preset is not None
client = _make_webhooks_client(mocker, ids=[42, 43, 44])
ids = await _register_preset_webhooks(client, preset, "https://example.com/wh")
assert ids == [42, 43, 44]