diff --git a/CHANGELOG.md b/CHANGELOG.md index b056784..0329ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Add Forward Webhook endpoint creation, listing, retrieval, partial update, deletion, enable/disable and synthetic test delivery. +- Add raw-body webhook signature verification with HMAC-SHA256, multiple v1 signatures and a 300-second timestamp window; include receiver examples and offline contracts. + + ## [0.8.0] ### Fixed diff --git a/README.md b/README.md index aa50764..0275478 100644 --- a/README.md +++ b/README.md @@ -764,3 +764,7 @@ Runnable scenarios live in `examples/`, organized by mode, with one file per sce ## License Released under the [MIT License](LICENSE). + +## Forward Webhooks (next release) + +Manage webhook endpoints and verify signed event deliveries. See [Webhook usage and receiver examples](docs/webhooks.md). diff --git a/docs/api/reference.md b/docs/api/reference.md index 0b6c81e..48e2d3a 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### webhooks + +```python +@cached_property +def webhooks() -> Webhooks +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### drives @@ -2274,6 +2285,17 @@ class AsyncForward(AsyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### webhooks + +```python +@cached_property +def webhooks() -> AsyncWebhooks +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### drives @@ -9817,6 +9839,394 @@ DELETE /vaults/{vault_id}. identity_id selects identity ownership with a PAT; omit it with a SAT. + + +# qca.forward.resources.webhooks + + + +## Webhooks + +```python +class Webhooks(SyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +#### endpoints + +```python +@cached_property +def endpoints() -> WebhookEndpoints +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +## WebhookEndpoints + +```python +class WebhookEndpoints(SyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +#### create + +```python +def create( + *, + url: str, + events: List[str], + description: Union[str, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpointCreateResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints. Requires a PAT or administrator SAT. + + + +#### list + +```python +def list( + *, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> SyncPage[WebhookEndpoint] +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +GET /webhook/endpoints. Requires a PAT or administrator SAT. + + + +#### retrieve + +```python +def retrieve( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### update + +```python +def update( + endpoint_id: str, + *, + url: Union[str, NotGiven] = NOT_GIVEN, + events: Union[List[str], NotGiven] = NOT_GIVEN, + description: Union[str, NotGiven] = NOT_GIVEN, + active: Union[bool, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### delete + +```python +def delete( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN) -> None +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### enable + +```python +def enable( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT. + + + +#### disable + +```python +def disable( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT. + + + +#### test + +```python +def test( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpointTestResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery. + + + +## AsyncWebhooks + +```python +class AsyncWebhooks(AsyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +#### endpoints + +```python +@cached_property +def endpoints() -> AsyncWebhookEndpoints +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +## AsyncWebhookEndpoints + +```python +class AsyncWebhookEndpoints(AsyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + + + +#### create + +```python +async def create( + *, + url: str, + events: List[str], + description: Union[str, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpointCreateResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints. Requires a PAT or administrator SAT. + + + +#### list + +```python +def list( + *, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> AsyncPaginator[WebhookEndpoint] +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +GET /webhook/endpoints. Requires a PAT or administrator SAT. + + + +#### retrieve + +```python +async def retrieve( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### update + +```python +async def update( + endpoint_id: str, + *, + url: Union[str, NotGiven] = NOT_GIVEN, + events: Union[List[str], NotGiven] = NOT_GIVEN, + description: Union[str, NotGiven] = NOT_GIVEN, + active: Union[bool, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### delete + +```python +async def delete( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN) -> None +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT. + + + +#### enable + +```python +async def enable( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT. + + + +#### disable + +```python +async def disable( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpoint +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT. + + + +#### test + +```python +async def test( + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> WebhookEndpointTestResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/webhooks.py) + +POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery. + # qca.forward.types.batch @@ -17513,6 +17923,255 @@ Select identity ownership with a PAT. Omit when using a SAT. #### name + + +# qca.forward.types.webhook\_endpoint + + + +## WebhookEndpointCreateResponse + +```python +class WebhookEndpointCreateResponse(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py) + +The signing secret is returned only when creating an endpoint. + + + +#### id + + + +#### url + + + +#### events + + + +#### description + + + +#### metadata + + + +#### active + + + +#### created\_at + + + +#### signing\_secret + + + +## WebhookEndpoint + +```python +class WebhookEndpoint(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py) + + + +#### id + + + +#### url + + + +#### events + + + +#### description + + + +#### metadata + + + +#### active + + + +#### created\_at + + + +#### updated\_at + + + +#### last\_success\_at + + + +#### last\_failure\_at + + + +#### disabled\_reason + + + +#### consecutive\_fail + + + +## WebhookEndpointTestResponse + +```python +class WebhookEndpointTestResponse(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint.py) + +Accepted delivery rows, not a delivery success report. + + + +#### event\_id + + + +#### delivery\_rows + + + +# qca.forward.types.webhook\_endpoint\_create\_params + + + +## WebhookEndpointCreateParams + +```python +class WebhookEndpointCreateParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_create_params.py) + + + +#### url + + + +#### events + + + +#### description + + + +#### metadata + + + +#### idempotency\_key + + + +# qca.forward.types.webhook\_endpoint\_list\_params + + + +## WebhookEndpointListParams + +```python +class WebhookEndpointListParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_list_params.py) + + + +#### limit + + + +#### after\_id + + + +# qca.forward.types.webhook\_endpoint\_update\_params + + + +## WebhookEndpointUpdateParams + +```python +class WebhookEndpointUpdateParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/webhook_endpoint_update_params.py) + + + +#### url + + + +#### events + + + +#### description + + + +#### active + + + +#### metadata + + + +# qca.forward.webhook\_signature + +Verify Forward delivery signatures before parsing the original request body. + + + +## WebhookVerificationError + +```python +class WebhookVerificationError(ValueError) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/webhook_signature.py) + +Missing, invalid, expired or mismatched delivery authentication. + + + +#### verify\_webhook\_signature + +```python +def verify_webhook_signature(payload: bytes, headers: Mapping[str, str], + secret: str) -> None +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/webhook_signature.py) + +Accept any matching v1 signature within 300 seconds; raise on failure. + +Use original body bytes. After verification, deduplicate using Webhook-ID. + # qca.managed diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..6714656 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,116 @@ +# Forward Webhooks + +Available in the next SDK release. Configure account notifications with a PAT or +administrator SAT. Identity SATs cannot manage webhook endpoints. Use the same +regional Forward client and shared authentication configuration as other APIs. + +## Endpoint operations + +All paths are relative to `/api/v1/forward`. + +| Method | Path | Result | +| --- | --- | --- | +| POST | `/webhook/endpoints` | Create; HTTP 201, includes `signing_secret` once | +| GET | `/webhook/endpoints` | List; `limit` and `after_id`, preserving server order | +| GET | `/webhook/endpoints/{endpoint_id}` | Retrieve | +| PUT | `/webhook/endpoints/{endpoint_id}` | Partial update | +| DELETE | `/webhook/endpoints/{endpoint_id}` | Delete; HTTP 204, no response body | +| POST | `/webhook/endpoints/{endpoint_id}/enable` | Enable | +| POST | `/webhook/endpoints/{endpoint_id}/disable` | Disable | +| POST | `/webhook/endpoints/{endpoint_id}/test` | HTTP 202, `event_id` and `delivery_rows` | + +- `events` accepts full event names or `*`. Strings remain open for future event + types; the SDK does not maintain a closed event enumeration. The current official + catalog includes `forward.schedule.archived`. +- Omitted update fields are unchanged. `active: false` disables an endpoint. + `events` replaces the subscription list. `metadata` merges keys; a null entry + deletes that key. Create metadata values are strings. +- Securely store the create response's `signing_secret` before leaving the creation + flow. List/retrieve/update cannot recover it. Do not log the create response. +- `test` only queues a synthetic event; 202 and a positive `delivery_rows` do not + confirm receipt. A disabled endpoint may return zero rows. Verify actual delivery + at your receiver. The SDK does not poll delivery state or automatically replay + a test POST without an idempotency key. + +## Manage subscriptions + +The following uses an already configured `client` and a callback URL that you +control. Only call `test` when that receiver is ready. Use a unique idempotency key +for creation, and reuse it only for the same request. + +```python +created = client.webhooks.endpoints.create( + url=callback_url, + events=["forward.schedule.archived"], + metadata={"team": "sdk"}, + idempotency_key=create_key, +) +# Persist created.signing_secret securely; never print the response. +endpoint = client.webhooks.endpoints.retrieve(created.id) +client.webhooks.endpoints.update(created.id, active=False, metadata={"old_key": None}) +client.webhooks.endpoints.enable(created.id) +accepted = client.webhooks.endpoints.test(created.id) +for endpoint in client.webhooks.endpoints.list(limit=20): + print(endpoint.id) +``` + +`AsyncForward` exposes the same methods: await individual requests and use +`async for` on `client.webhooks.endpoints.list(...)`. Raw response wrappers remain +available for status, headers and request IDs. + +## Verify deliveries + +See [the runnable example](../examples/forward/webhooks.py): + +```python +from qca.forward import verify_webhook_signature +import json + +verify_webhook_signature(raw_body, request_headers, signing_secret) +event = json.loads(raw_body) +``` + +The synchronous verifier also works in async HTTP handlers. + +Pass the **original HTTP request body bytes**, all request headers, and the stored +`whsec_...` secret. Verification is local and makes no API call. It checks +`Webhook-ID`, `Webhook-Timestamp` and `Webhook-Signature`, decodes the secret with +standard Base64, and verifies HMAC-SHA256 over `id.timestamp.raw_body`. It accepts +any matching `v1` signature in the whitespace-separated header, checks a fixed +300-second past/future window, and rejects malformed or mismatched input with a +webhook verification error. Keep the receiver clock synchronized. + +Parse JSON only after verification; do not reserialize it before checking the +signature. Preserve unknown event types and fields. Ordinary events contain +`data.type`; the synthetic test event instead has `endpoint_id`, `emitted_at` and +`note`, with no required `data`. The `Webhook-Event-Type` header is not part of the +signed message; use the verified payload for business decisions. + +In a real HTTP handler, reject verification failures, bound the body size, store +accepted events durably, and deduplicate using `Webhook-ID` before acknowledging +with 2xx. Retries share the same ID. The SDK does not supply a queue or a deduplication +database. The examples only parse an authenticated delivery and list endpoints; +they do not start a production receiver or create subscriptions automatically. + +## Clean up a test endpoint + +Run this step separately for a temporary test endpoint, after your receiver has +confirmed receipt and successfully verified the expected test delivery. Keep the +endpoint active while waiting: disabling or deleting it can discard queued +deliveries. HTTP 202 and `delivery_rows` confirm queuing, not receipt. + +```python +client.webhooks.endpoints.disable(created.id) +client.webhooks.endpoints.delete(created.id) +``` + +## Validation boundary + +Offline tests cover all eight HTTP contracts, errors, cursor paging, omitted/null/ +false updates, empty bodies, signature vectors, tampering, expired/future timestamps, +multiple signature candidates and unknown fields. Live CN/Global callback delivery +requires a reachable receiver controlled by the application team and is not implied +by these tests. + +References: [API overview](https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview), +[receiving events](https://docs.qoder.com/zh/cloud-agents/api/webhooks/receive). diff --git a/examples/forward/webhooks.py b/examples/forward/webhooks.py new file mode 100644 index 0000000..b86bdbc --- /dev/null +++ b/examples/forward/webhooks.py @@ -0,0 +1,29 @@ +"""List webhook endpoints; parse_delivery can be used inside your HTTP receiver. + +Run: python -m examples.forward.webhooks +The management client uses the shared PAT/SA configuration (administrator SAT required). +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + +from examples.common.live import Run, run_cli +from qca import Forward +from qca.forward import verify_webhook_signature + + +def parse_delivery(raw_body: bytes, headers: Mapping[str, str], secret: str) -> Any: + verify_webhook_signature(raw_body, headers, secret) + return json.loads(raw_body) + + +def run(client: Forward, context: Run) -> None: + for endpoint in client.webhooks.endpoints.list(limit=20): + context.output("webhook_endpoint", {"id": endpoint.id, "active": endpoint.active}) + + +if __name__ == "__main__": + run_cli("forward", Forward, {"webhooks": run}) diff --git a/src/qca/forward/__init__.py b/src/qca/forward/__init__.py index 0ff954d..49bbc87 100644 --- a/src/qca/forward/__init__.py +++ b/src/qca/forward/__init__.py @@ -21,6 +21,8 @@ from .resources.realtime import ( RealtimeTimeoutError as RealtimeTimeoutError, ) +from .webhook_signature import WebhookVerificationError as WebhookVerificationError +from .webhook_signature import verify_webhook_signature as verify_webhook_signature Client = Forward AsyncClient = AsyncForward diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py index 437fe84..f2259b7 100644 --- a/src/qca/forward/_client.py +++ b/src/qca/forward/_client.py @@ -22,9 +22,14 @@ from .resources.templates import AsyncTemplates, Templates from .resources.usage import AsyncUsage, Usage from .resources.vaults.vaults import AsyncVaults, Vaults +from .resources.webhooks import AsyncWebhooks, Webhooks class Forward(SyncAPIClient): + @cached_property + def webhooks(self) -> Webhooks: + return Webhooks(self) + @cached_property def drives(self) -> Drives: return Drives(self) @@ -102,6 +107,10 @@ def models(self) -> Models: class AsyncForward(AsyncAPIClient): + @cached_property + def webhooks(self) -> AsyncWebhooks: + return AsyncWebhooks(self) + @cached_property def drives(self) -> AsyncDrives: return AsyncDrives(self) diff --git a/src/qca/forward/resources/webhooks.py b/src/qca/forward/resources/webhooks.py new file mode 100644 index 0000000..edfabdb --- /dev/null +++ b/src/qca/forward/resources/webhooks.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Any, Dict, List, Union + +import httpx + +from qca.common._resource import AsyncAPIResource, SyncAPIResource +from qca.common._types import NOT_GIVEN, NotGiven +from qca.common._utils import make_request_options, path_template +from qca.common.pagination import AsyncPaginator, SyncPage +from qca.forward.types.webhook_endpoint import ( + WebhookEndpoint, + WebhookEndpointCreateResponse, + WebhookEndpointTestResponse, +) + + +class Webhooks(SyncAPIResource): + @cached_property + def endpoints(self) -> WebhookEndpoints: + return WebhookEndpoints(self._client) + + +class WebhookEndpoints(SyncAPIResource): + def create( + self, + *, + url: str, + events: List[str], + description: Union[str, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpointCreateResponse: + """POST /webhook/endpoints. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints") + options = make_request_options( + body={"url": url, "events": events, "description": description, "metadata": metadata}, + query={}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("POST", _path, cast_to=WebhookEndpointCreateResponse, options=options) + + def list( + self, + *, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SyncPage[WebhookEndpoint]: + """GET /webhook/endpoints. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints") + options = make_request_options( + body={}, + query={"limit": limit, "after_id": after_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options, page_style="Page") + + def retrieve( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options) + + def update( + self, + endpoint_id: str, + *, + url: Union[str, NotGiven] = NOT_GIVEN, + events: Union[List[str], NotGiven] = NOT_GIVEN, + description: Union[str, NotGiven] = NOT_GIVEN, + active: Union[bool, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={"url": url, "events": events, "description": description, "active": active, "metadata": metadata}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("PUT", _path, cast_to=WebhookEndpoint, options=options) + + def delete( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> None: + """DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return self._client.request("DELETE", _path, cast_to=None, options=options) + + def enable( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/enable", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options) + + def disable( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/disable", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options) + + def test( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpointTestResponse: + """POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/test", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return self._client.request("POST", _path, cast_to=WebhookEndpointTestResponse, options=options) + + +class AsyncWebhooks(AsyncAPIResource): + @cached_property + def endpoints(self) -> AsyncWebhookEndpoints: + return AsyncWebhookEndpoints(self._client) + + +class AsyncWebhookEndpoints(AsyncAPIResource): + async def create( + self, + *, + url: str, + events: List[str], + description: Union[str, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpointCreateResponse: + """POST /webhook/endpoints. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints") + options = make_request_options( + body={"url": url, "events": events, "description": description, "metadata": metadata}, + query={}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("POST", _path, cast_to=WebhookEndpointCreateResponse, options=options) + + def list( + self, + *, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AsyncPaginator[WebhookEndpoint]: + """GET /webhook/endpoints. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints") + options = make_request_options( + body={}, + query={"limit": limit, "after_id": after_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return AsyncPaginator( + lambda: self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options, page_style="Page") + ) + + async def retrieve( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """GET /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("GET", _path, cast_to=WebhookEndpoint, options=options) + + async def update( + self, + endpoint_id: str, + *, + url: Union[str, NotGiven] = NOT_GIVEN, + events: Union[List[str], NotGiven] = NOT_GIVEN, + description: Union[str, NotGiven] = NOT_GIVEN, + active: Union[bool, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str | None], NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """PUT /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={"url": url, "events": events, "description": description, "active": active, "metadata": metadata}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("PUT", _path, cast_to=WebhookEndpoint, options=options) + + async def delete( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> None: + """DELETE /webhook/endpoints/{endpoint_id}. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return await self._client.request("DELETE", _path, cast_to=None, options=options) + + async def enable( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """POST /webhook/endpoints/{endpoint_id}/enable. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/enable", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return await self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options) + + async def disable( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpoint: + """POST /webhook/endpoints/{endpoint_id}/disable. Requires a PAT or administrator SAT.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/disable", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return await self._client.request("POST", _path, cast_to=WebhookEndpoint, options=options) + + async def test( + self, + endpoint_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> WebhookEndpointTestResponse: + """POST /webhook/endpoints/{endpoint_id}/test. Queues a test event; 202 does not confirm delivery.""" + _path = path_template("/webhook/endpoints/{endpoint_id}/test", endpoint_id=endpoint_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return await self._client.request("POST", _path, cast_to=WebhookEndpointTestResponse, options=options) diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py index 581bad2..934732e 100644 --- a/src/qca/forward/types/__init__.py +++ b/src/qca/forward/types/__init__.py @@ -247,6 +247,12 @@ from .vault_credential_update_params import VaultCredentialUpdateAuth as VaultCredentialUpdateAuth from .vault_credential_update_params import VaultCredentialUpdateParams as VaultCredentialUpdateParams from .vault_list_params import VaultListParams as VaultListParams +from .webhook_endpoint import WebhookEndpoint as WebhookEndpoint +from .webhook_endpoint import WebhookEndpointCreateResponse as WebhookEndpointCreateResponse +from .webhook_endpoint import WebhookEndpointTestResponse as WebhookEndpointTestResponse +from .webhook_endpoint_create_params import WebhookEndpointCreateParams as WebhookEndpointCreateParams +from .webhook_endpoint_list_params import WebhookEndpointListParams as WebhookEndpointListParams +from .webhook_endpoint_update_params import WebhookEndpointUpdateParams as WebhookEndpointUpdateParams _namespace = dict( globals(), diff --git a/src/qca/forward/types/webhook_endpoint.py b/src/qca/forward/types/webhook_endpoint.py new file mode 100644 index 0000000..9ba2655 --- /dev/null +++ b/src/qca/forward/types/webhook_endpoint.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import Field + +from qca.common._models import BaseModel + + +class WebhookEndpointCreateResponse(BaseModel): + """The signing secret is returned only when creating an endpoint.""" + + id: str | None = None + url: str | None = None + events: list[str] | None = None + description: str | None = None + metadata: dict[str, Any] | None = None + active: bool | None = None + created_at: datetime | None = None + signing_secret: str | None = Field(default=None, repr=False) + + +class WebhookEndpoint(BaseModel): + id: str | None = None + url: str | None = None + events: list[str] | None = None + description: str | None = None + metadata: dict[str, Any] | None = None + active: bool | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + last_success_at: datetime | None = None + last_failure_at: datetime | None = None + disabled_reason: str | None = None + consecutive_fail: int | None = None + + +class WebhookEndpointTestResponse(BaseModel): + """Accepted delivery rows, not a delivery success report.""" + + event_id: int | None = None + delivery_rows: int | None = None diff --git a/src/qca/forward/types/webhook_endpoint_create_params.py b/src/qca/forward/types/webhook_endpoint_create_params.py new file mode 100644 index 0000000..a125ba9 --- /dev/null +++ b/src/qca/forward/types/webhook_endpoint_create_params.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing_extensions import Required, TypedDict + + +class WebhookEndpointCreateParams(TypedDict, total=False): + url: Required[str] + events: Required[list[str]] + description: str + metadata: dict[str, str] + idempotency_key: str diff --git a/src/qca/forward/types/webhook_endpoint_list_params.py b/src/qca/forward/types/webhook_endpoint_list_params.py new file mode 100644 index 0000000..a625cd8 --- /dev/null +++ b/src/qca/forward/types/webhook_endpoint_list_params.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing_extensions import TypedDict + + +class WebhookEndpointListParams(TypedDict, total=False): + limit: int + after_id: str diff --git a/src/qca/forward/types/webhook_endpoint_update_params.py b/src/qca/forward/types/webhook_endpoint_update_params.py new file mode 100644 index 0000000..271075d --- /dev/null +++ b/src/qca/forward/types/webhook_endpoint_update_params.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing_extensions import TypedDict + + +class WebhookEndpointUpdateParams(TypedDict, total=False): + url: str + events: list[str] + description: str + active: bool + metadata: dict[str, str | None] diff --git a/src/qca/forward/webhook_signature.py b/src/qca/forward/webhook_signature.py new file mode 100644 index 0000000..2cd6248 --- /dev/null +++ b/src/qca/forward/webhook_signature.py @@ -0,0 +1,63 @@ +"""Verify Forward delivery signatures before parsing the original request body.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import time +from collections.abc import Mapping + + +class WebhookVerificationError(ValueError): + """Missing, invalid, expired or mismatched delivery authentication.""" + + +def verify_webhook_signature(payload: bytes, headers: Mapping[str, str], secret: str) -> None: + """Accept any matching v1 signature within 300 seconds; raise on failure. + + Use original body bytes. After verification, deduplicate using Webhook-ID. + """ + + def invalid() -> WebhookVerificationError: + return WebhookVerificationError("webhook signature verification failed") + + def header(name: str) -> str: + values = [v for k, v in headers.items() if k.lower() == name] + return values[0] if len(values) == 1 else "" + + if not isinstance(payload, bytes): + raise invalid() + event_id = header("webhook-id") + timestamp = header("webhook-timestamp") + if ( + not event_id + or any(c in event_id for c in ".\r\n") + or not timestamp + or not timestamp.isascii() + or not timestamp.isdecimal() + or len(timestamp) > 19 + or not secret.startswith("whsec_") + ): + raise invalid() + now = int(time.time()) + if not now - 300 <= int(timestamp) <= now + 300: + raise invalid() + try: + key = base64.b64decode(secret[6:], validate=True) + except (ValueError, binascii.Error): + raise invalid() from None + if not 24 <= len(key) <= 64 or base64.b64encode(key).decode("ascii") != secret[6:]: + raise invalid() + expected = hmac.new(key, (event_id + "." + timestamp + ".").encode("utf-8") + payload, hashlib.sha256).digest() + for signature in header("webhook-signature").split(): + if not signature.startswith("v1,"): + continue + try: + candidate = base64.b64decode(signature[3:], validate=True) + except (ValueError, binascii.Error): + continue + if hmac.compare_digest(expected, candidate): + return + raise invalid() diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json index b38d33a..cd17ff7 100644 --- a/tests/fixtures/api-contracts.json +++ b/tests/fixtures/api-contracts.json @@ -2036,12 +2036,87 @@ "identity_id" ], "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.create", + "method": "POST", + "path": "/webhook/endpoints", + "required": [ + "events", + "url" + ], + "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.list", + "method": "GET", + "path": "/webhook/endpoints", + "required": [], + "kind": "page" + }, + { + "id": "forward.webhooks.endpoints.retrieve", + "method": "GET", + "path": "/webhook/endpoints/{endpoint_id}", + "required": [ + "endpoint_id" + ], + "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.update", + "method": "PUT", + "path": "/webhook/endpoints/{endpoint_id}", + "required": [ + "endpoint_id" + ], + "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.delete", + "method": "DELETE", + "path": "/webhook/endpoints/{endpoint_id}", + "required": [ + "endpoint_id" + ], + "kind": "empty" + }, + { + "id": "forward.webhooks.endpoints.enable", + "method": "POST", + "path": "/webhook/endpoints/{endpoint_id}/enable", + "required": [ + "endpoint_id" + ], + "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.disable", + "method": "POST", + "path": "/webhook/endpoints/{endpoint_id}/disable", + "required": [ + "endpoint_id" + ], + "kind": "model" + }, + { + "id": "forward.webhooks.endpoints.test", + "method": "POST", + "path": "/webhook/endpoints/{endpoint_id}/test", + "required": [ + "endpoint_id" + ], + "kind": "model" } ], "extensions": [ { "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)", "operations": 6 + }, + { + "source": "https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview (2026-10-22); Forward webhook handler and DTO cross-check", + "operations": 8 } ] } diff --git a/tests/fixtures/webhook-signature.json b/tests/fixtures/webhook-signature.json new file mode 100644 index 0000000..0e78223 --- /dev/null +++ b/tests/fixtures/webhook-signature.json @@ -0,0 +1,7 @@ +{ + "secret": "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + "id": "whe_0123456789abcdefghijkl", + "timestamp": "1767225600", + "payload": "{\"type\":\"session.created\",\"data\":{\"id\":\"sess_01\"}}", + "signature": "v1,lMKooyqBqXRe60OmeImJYaDFJ10DGzkw1CMAyVjLbFs=" +} diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py index 5fd3568..78b03b2 100644 --- a/tests/test_resource_surface.py +++ b/tests/test_resource_surface.py @@ -39,7 +39,7 @@ def test_contract_fixture_has_pinned_provenance(): "commit": "bc5bc655d70b67fcd88623320ac01896a7360d8a", "captured_at": "2026-09-22", } - assert len(CONTRACT_FIXTURE["contracts"]) == 221 + assert len(CONTRACT_FIXTURE["contracts"]) == 229 @pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..100c80e --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,151 @@ +import base64 +import inspect +import json +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest + +from qca import AsyncForward, Forward, InternalServerError +from qca.forward import WebhookVerificationError, verify_webhook_signature + + +async def resolve(value): + return await value if inspect.isawaitable(value) else value + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("auth", [{"pat": "token"}, {"auth_mode": "sa", "access_token": "token"}]) +async def test_webhook_lifecycle(async_, auth): + requests = [] + + def handle(r): + requests.append(r) + assert r.headers["authorization"] == "Bearer token" + if r.method == "POST" and r.url.path.endswith("/endpoints"): + assert r.headers["Idempotency-Key"] == "create-once" + assert json.loads(r.content) == {"url": "https://hooks.example.com/qca", "events": ["future.event"]} + return httpx.Response(201, json={"id": "endpoint", "signing_secret": "synthetic", "future": 1}) + if r.method == "PUT": + assert json.loads(r.content) == {"active": False, "metadata": {"remove": None, "keep": "value"}} + return httpx.Response(200, json={"id": "endpoint", "active": False, "last_success_at": None}) + assert not r.content + if r.method == "DELETE": + return httpx.Response(204) + if r.url.path.endswith("/test"): + return httpx.Response(202, json={"event_id": 7, "delivery_rows": 0}) + return httpx.Response(200, json={"id": "endpoint", "events": ["future.event"], "future": 1}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + client = (AsyncForward if async_ else Forward)(http_client=http, **auth) + try: + resource = client.webhooks.endpoints + created = await resolve( + resource.create(url="https://hooks.example.com/qca", events=["future.event"], idempotency_key="create-once") + ) + assert created.signing_secret == "synthetic" and "synthetic" not in repr(created) + updated = await resolve( + resource.update("opaque /?%#", active=False, metadata={"remove": None, "keep": "value"}) + ) + assert updated.active is False and updated.last_success_at is None + for name in ["retrieve", "enable", "disable"]: + endpoint = await resolve(getattr(resource, name)("opaque /?%#")) + assert endpoint.events == ["future.event"] and endpoint.model_extra["future"] == 1 + result = await resolve(resource.test("opaque /?%#")) + assert result.delivery_rows == 0 + assert await resolve(resource.delete("opaque /?%#")) is None + for r in requests[1:]: + assert "/webhook/endpoints/opaque%20%2F%3F%25%23" in r.url.raw_path.decode() + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_webhook_cursor_order_and_unsafe_retry(async_): + calls = [] + + def handle(r): + calls.append(r) + if r.method == "POST": + return httpx.Response(503, json={"error": {"message": "unavailable"}}) + assert "page" not in r.url.params and r.url.params["limit"] == "2" + if len(calls) == 1: + return httpx.Response( + 200, json={"data": [{"id": "z"}, {"id": "a"}], "last_id": "opaque/+?", "has_more": True} + ) + assert r.url.params["after_id"] == "opaque/+?" + return httpx.Response(200, json={"data": [], "last_id": None, "first_id": None, "has_more": False}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + client = (AsyncForward if async_ else Forward)(pat="test", http_client=http, max_retries=2) + try: + page = await resolve(client.webhooks.endpoints.list(limit=2)) + items = [item async for item in page] if async_ else list(page) + assert [i.id for i in items] == ["z", "a"] and len(calls) == 2 + with pytest.raises(InternalServerError): + await resolve(client.webhooks.endpoints.test("id")) + assert len(calls) == 3 + finally: + await resolve(client.close()) + + +@pytest.fixture +def vector(): + return json.loads((Path(__file__).parent / "fixtures/webhook-signature.json").read_text()) + + +def test_webhook_signature_vector_and_window(vector): + v = vector + headers = { + "webhook-id": v["id"], + "Webhook-Timestamp": v["timestamp"], + "Webhook-Signature": "v2,unknown v1,invalid " + v["signature"], + } + for delta in [-300, 0, 300]: + with patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"]) + delta): + verify_webhook_signature(v["payload"].encode(), headers, v["secret"]) + for delta in [-301, 301]: + with ( + patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"]) + delta), + pytest.raises(WebhookVerificationError), + ): + verify_webhook_signature(v["payload"].encode(), headers, v["secret"]) + with patch("qca.forward.webhook_signature.time.time", return_value=int(v["timestamp"])): + for name, value in [ + ("Webhook-Timestamp", "9223372036854775807"), + ("Webhook-Timestamp", "-1"), + ("Webhook-Signature", "v2,abc"), + ("webhook-id", "other"), + ]: + with pytest.raises(WebhookVerificationError): + verify_webhook_signature(v["payload"].encode(), {**headers, name: value}, v["secret"]) + with pytest.raises(WebhookVerificationError): + verify_webhook_signature((v["payload"] + " ").encode(), headers, v["secret"]) + for secret in ["", "whsec_", "whsec_bad!", "whsec_" + base64.b64encode(b"too short").decode()]: + with pytest.raises(WebhookVerificationError): + verify_webhook_signature(v["payload"].encode(), headers, secret) + + +def test_receiver_example_preserves_unknown_and_synthetic_events(): + import hashlib + import hmac + import time + + from examples.forward.webhooks import parse_delivery + + key = b"0123456789abcdef0123456789abcdef" + secret = "whsec_" + base64.b64encode(key).decode() + for event in [ + {"type": "event", "data": {"type": "future.event", "extra": 42}}, + {"type": "event", "endpoint_id": "id", "note": "synthetic"}, + ]: + body = json.dumps(event).encode() + timestamp = str(int(time.time())) + signature = base64.b64encode( + hmac.new(key, b"whe_test." + timestamp.encode() + b"." + body, hashlib.sha256).digest() + ).decode() + headers = {"Webhook-ID": "whe_test", "Webhook-Timestamp": timestamp, "Webhook-Signature": "v1," + signature} + assert parse_delivery(body, headers, secret) == event + with pytest.raises(WebhookVerificationError): + parse_delivery(body + b" ", headers, secret)