mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2025-12-21 08:40:10 -06:00
Feat: OLAP Table for CEL Eval Failures (#2012)
* feat: add table, wire up partitioning * feat: wire failures into the OLAP db from rabbit * feat: bubble failures up to controller * fix: naming * fix: hack around enum type * fix: typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: typos * fix: migration name * feat: log debug failure * feat: pub message from debug endpoint to log failure * fix: error handling * fix: use ingestor * fix: olap suffix * fix: pass source through * fix: dont log ingest failure * fix: rm debug as enum opt * chore: gen * Feat: Webhooks (#1978) * feat: migration + go gen * feat: non unique source name * feat: api types * fix: rm cruft * feat: initial api for webhooks * feat: handle encryption of incoming keys * fix: nil pointer errors * fix: import * feat: add endpoint for incoming webhooks * fix: naming * feat: start wiring up basic auth * feat: wire up cel event parsing * feat: implement authentication * fix: hack for plain text content * feat: add source to enum * feat: add source name enum * feat: db source name enum fix * fix: use source name enums * feat: nest sources * feat: first pass at stripe * fix: clean up source name passing * fix: use unique name for webhook * feat: populator test * fix: null values * fix: ordering * fix: rm unnecessary index * fix: validation * feat: validation on create * fix: lint * fix: naming * feat: wire triggering webhook name through to events table * feat: cleanup + python gen + e2e test for basic auth * feat: query to insert webhook validation errors * refactor: auth handler * fix: naming * refactor: validation errors, part II * feat: wire up writes through olap * fix: linting, fallthrough case * fix: validation * feat: tests for failure cases for basic auth * feat: expand tests * fix: correctly return 404 out of task getter * chore: generated stuff * fix: rm cruft * fix: longer sleep * debug: print name + events to logs * feat: limit to N * feat: add limit env var * debug: ci test * fix: apply namespaces to keys * fix: namespacing, part ii * fix: sdk config * fix: handle prefixing * feat: handle partitioning logic * chore: gen * feat: add webhook limit * feat: wire up limits * fix: gen * fix: reverse order of generic fallthrough * fix: comment for potential unexpected behavior * fix: add check constraints, improve error handling * chore: gen * chore: gen * fix: improve naming * feat: scaffold webhooks page * feat: sidebar * feat: first pass at page * feat: improve feedback on UI * feat: initial work on create modal * feat: change default to basic * fix: openapi spec discriminated union * fix: go side * feat: start wiring up placeholders for stripe and github * feat: pre-populated fields for Stripe + Github * feat: add name section * feat: copy improvements, show URL * feat: UI cleanup * fix: check if tenant populator errors * feat: add comments * chore: gen again * fix: default name * fix: styling * fix: improve stripe header processing * feat: docs, part 1 * fix: lint * fix: migration order * feat: implement rate limit per-webhook * feat: comment * feat: clean up docs * chore: gen * fix: migration versions * fix: olap naming * fix: partitions * chore: gen * feat: store webhook cel eval failures properly * fix: pk order * fix: auth tweaks, move fetches out of populator * fix: pgtype.Text instead of string pointer * chore: gen --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
643
examples/python/webhooks/test_webhooks.py
Normal file
643
examples/python/webhooks/test_webhooks.py
Normal file
@@ -0,0 +1,643 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from examples.webhooks.worker import WebhookInput
|
||||
from hatchet_sdk import Hatchet
|
||||
from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi
|
||||
from hatchet_sdk.clients.rest.models.v1_create_webhook_request import (
|
||||
V1CreateWebhookRequest,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import (
|
||||
V1CreateWebhookRequestAPIKey,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import (
|
||||
V1CreateWebhookRequestBasicAuth,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import (
|
||||
V1CreateWebhookRequestHMAC,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_event import V1Event
|
||||
from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus
|
||||
from hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import (
|
||||
V1WebhookHMACAlgorithm,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import (
|
||||
V1WebhookHMACEncoding,
|
||||
)
|
||||
from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName
|
||||
|
||||
TEST_BASIC_USERNAME = "test_user"
|
||||
TEST_BASIC_PASSWORD = "test_password"
|
||||
TEST_API_KEY_HEADER = "X-API-Key"
|
||||
TEST_API_KEY_VALUE = "test_api_key_123"
|
||||
TEST_HMAC_SIGNATURE_HEADER = "X-Signature"
|
||||
TEST_HMAC_SECRET = "test_hmac_secret"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_body() -> WebhookInput:
|
||||
return WebhookInput(type="test", message="Hello, world!")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_run_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_start() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_hmac_signature(
|
||||
payload: bytes,
|
||||
secret: str,
|
||||
algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,
|
||||
encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,
|
||||
) -> str:
|
||||
algorithm_map = {
|
||||
V1WebhookHMACAlgorithm.SHA1: hashlib.sha1,
|
||||
V1WebhookHMACAlgorithm.SHA256: hashlib.sha256,
|
||||
V1WebhookHMACAlgorithm.SHA512: hashlib.sha512,
|
||||
V1WebhookHMACAlgorithm.MD5: hashlib.md5,
|
||||
}
|
||||
|
||||
hash_func = algorithm_map[algorithm]
|
||||
signature = hmac.new(secret.encode(), payload, hash_func).digest()
|
||||
|
||||
if encoding == V1WebhookHMACEncoding.HEX:
|
||||
return signature.hex()
|
||||
if encoding == V1WebhookHMACEncoding.BASE64:
|
||||
return base64.b64encode(signature).decode()
|
||||
if encoding == V1WebhookHMACEncoding.BASE64URL:
|
||||
return base64.urlsafe_b64encode(signature).decode()
|
||||
|
||||
raise ValueError(f"Unsupported encoding: {encoding}")
|
||||
|
||||
|
||||
async def send_webhook_request(
|
||||
url: str,
|
||||
body: WebhookInput,
|
||||
auth_type: str,
|
||||
auth_data: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> aiohttp.ClientResponse:
|
||||
request_headers = headers or {}
|
||||
auth = None
|
||||
|
||||
if auth_type == "BASIC" and auth_data:
|
||||
auth = aiohttp.BasicAuth(auth_data["username"], auth_data["password"])
|
||||
elif auth_type == "API_KEY" and auth_data:
|
||||
request_headers[auth_data["header_name"]] = auth_data["api_key"]
|
||||
elif auth_type == "HMAC" and auth_data:
|
||||
payload = json.dumps(body.model_dump()).encode()
|
||||
signature = create_hmac_signature(
|
||||
payload,
|
||||
auth_data["secret"],
|
||||
auth_data.get("algorithm", V1WebhookHMACAlgorithm.SHA256),
|
||||
auth_data.get("encoding", V1WebhookHMACEncoding.HEX),
|
||||
)
|
||||
request_headers[auth_data["header_name"]] = signature
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
return await session.post(
|
||||
url, json=body.model_dump(), auth=auth, headers=request_headers
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_event(
|
||||
hatchet: Hatchet,
|
||||
webhook_name: str,
|
||||
test_start: datetime,
|
||||
) -> V1Event | None:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
events = await hatchet.event.aio_list(since=test_start)
|
||||
|
||||
if events.rows is None:
|
||||
return None
|
||||
|
||||
return next(
|
||||
(
|
||||
event
|
||||
for event in events.rows
|
||||
if event.triggering_webhook_name == webhook_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_workflow_run(
|
||||
hatchet: Hatchet, event_id: str, test_start: datetime
|
||||
) -> V1TaskSummary | None:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
runs = await hatchet.runs.aio_list(
|
||||
since=test_start,
|
||||
additional_metadata={
|
||||
"hatchet__event_id": event_id,
|
||||
},
|
||||
)
|
||||
|
||||
if len(runs.rows) == 0:
|
||||
return None
|
||||
|
||||
return runs.rows[0]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def basic_auth_webhook(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
username: str = TEST_BASIC_USERNAME,
|
||||
password: str = TEST_BASIC_PASSWORD,
|
||||
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
|
||||
) -> AsyncGenerator[V1Webhook, None]:
|
||||
## Hack to get the API client
|
||||
client = hatchet.metrics.client()
|
||||
webhook_api = WebhookApi(client)
|
||||
|
||||
webhook_request = V1CreateWebhookRequestBasicAuth(
|
||||
sourceName=source_name,
|
||||
name=f"test-webhook-basic-{test_run_id}",
|
||||
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
|
||||
authType="BASIC",
|
||||
auth=V1WebhookBasicAuth(
|
||||
username=username,
|
||||
password=password,
|
||||
),
|
||||
)
|
||||
|
||||
incoming_webhook = webhook_api.v1_webhook_create(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
|
||||
)
|
||||
|
||||
try:
|
||||
yield incoming_webhook
|
||||
finally:
|
||||
webhook_api.v1_webhook_delete(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_webhook=incoming_webhook.name,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def api_key_webhook(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
header_name: str = TEST_API_KEY_HEADER,
|
||||
api_key: str = TEST_API_KEY_VALUE,
|
||||
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
|
||||
) -> AsyncGenerator[V1Webhook, None]:
|
||||
client = hatchet.metrics.client()
|
||||
webhook_api = WebhookApi(client)
|
||||
|
||||
webhook_request = V1CreateWebhookRequestAPIKey(
|
||||
sourceName=source_name,
|
||||
name=f"test-webhook-apikey-{test_run_id}",
|
||||
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
|
||||
authType="API_KEY",
|
||||
auth=V1WebhookAPIKeyAuth(
|
||||
headerName=header_name,
|
||||
apiKey=api_key,
|
||||
),
|
||||
)
|
||||
|
||||
incoming_webhook = webhook_api.v1_webhook_create(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
|
||||
)
|
||||
|
||||
try:
|
||||
yield incoming_webhook
|
||||
finally:
|
||||
webhook_api.v1_webhook_delete(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_webhook=incoming_webhook.name,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def hmac_webhook(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER,
|
||||
signing_secret: str = TEST_HMAC_SECRET,
|
||||
algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,
|
||||
encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,
|
||||
source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,
|
||||
) -> AsyncGenerator[V1Webhook, None]:
|
||||
client = hatchet.metrics.client()
|
||||
webhook_api = WebhookApi(client)
|
||||
|
||||
webhook_request = V1CreateWebhookRequestHMAC(
|
||||
sourceName=source_name,
|
||||
name=f"test-webhook-hmac-{test_run_id}",
|
||||
eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type",
|
||||
authType="HMAC",
|
||||
auth=V1WebhookHMACAuth(
|
||||
algorithm=algorithm,
|
||||
encoding=encoding,
|
||||
signatureHeaderName=signature_header_name,
|
||||
signingSecret=signing_secret,
|
||||
),
|
||||
)
|
||||
|
||||
incoming_webhook = webhook_api.v1_webhook_create(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),
|
||||
)
|
||||
|
||||
try:
|
||||
yield incoming_webhook
|
||||
finally:
|
||||
webhook_api.v1_webhook_delete(
|
||||
tenant=hatchet.tenant_id,
|
||||
v1_webhook=incoming_webhook.name,
|
||||
)
|
||||
|
||||
|
||||
def url(tenant_id: str, webhook_name: str) -> str:
|
||||
return f"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}"
|
||||
|
||||
|
||||
async def assert_has_runs(
|
||||
hatchet: Hatchet,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
incoming_webhook: V1Webhook,
|
||||
) -> None:
|
||||
triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)
|
||||
assert triggered_event is not None
|
||||
assert (
|
||||
triggered_event.key
|
||||
== f"{hatchet.config.apply_namespace('webhook')}:{webhook_body.type}"
|
||||
)
|
||||
assert triggered_event.payload == webhook_body.model_dump()
|
||||
|
||||
workflow_run = await wait_for_workflow_run(
|
||||
hatchet, triggered_event.metadata.id, test_start
|
||||
)
|
||||
assert workflow_run is not None
|
||||
assert workflow_run.status == V1TaskStatus.COMPLETED
|
||||
assert workflow_run.additional_metadata is not None
|
||||
|
||||
assert (
|
||||
workflow_run.additional_metadata["hatchet__event_id"]
|
||||
== triggered_event.metadata.id
|
||||
)
|
||||
assert workflow_run.additional_metadata["hatchet__event_key"] == triggered_event.key
|
||||
assert workflow_run.status == V1TaskStatus.COMPLETED
|
||||
|
||||
|
||||
async def assert_event_not_created(
|
||||
hatchet: Hatchet,
|
||||
test_start: datetime,
|
||||
incoming_webhook: V1Webhook,
|
||||
) -> None:
|
||||
triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)
|
||||
assert triggered_event is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_basic_auth_success(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"BASIC",
|
||||
{"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data == {"message": "ok"}
|
||||
|
||||
await assert_has_runs(
|
||||
hatchet,
|
||||
test_start,
|
||||
webhook_body,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"username,password",
|
||||
[
|
||||
("test_user", "incorrect_password"),
|
||||
("incorrect_user", "test_password"),
|
||||
("incorrect_user", "incorrect_password"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_basic_auth_failure(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""Test basic authentication failures."""
|
||||
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"BASIC",
|
||||
{"username": username, "password": password},
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_basic_auth_missing_credentials(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_api_key_success(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"API_KEY",
|
||||
{"header_name": TEST_API_KEY_HEADER, "api_key": TEST_API_KEY_VALUE},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data == {"message": "ok"}
|
||||
|
||||
await assert_has_runs(
|
||||
hatchet,
|
||||
test_start,
|
||||
webhook_body,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_key",
|
||||
[
|
||||
"incorrect_api_key",
|
||||
"",
|
||||
"partial_key",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_api_key_failure(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
api_key: str,
|
||||
) -> None:
|
||||
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"API_KEY",
|
||||
{"header_name": TEST_API_KEY_HEADER, "api_key": api_key},
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_api_key_missing_header(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_hmac_success(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"HMAC",
|
||||
{
|
||||
"header_name": TEST_HMAC_SIGNATURE_HEADER,
|
||||
"secret": TEST_HMAC_SECRET,
|
||||
"algorithm": V1WebhookHMACAlgorithm.SHA256,
|
||||
"encoding": V1WebhookHMACEncoding.HEX,
|
||||
},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data == {"message": "ok"}
|
||||
|
||||
await assert_has_runs(
|
||||
hatchet,
|
||||
test_start,
|
||||
webhook_body,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"algorithm,encoding",
|
||||
[
|
||||
(V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX),
|
||||
(V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64),
|
||||
(V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL),
|
||||
(V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_hmac_different_algorithms_and_encodings(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
algorithm: V1WebhookHMACAlgorithm,
|
||||
encoding: V1WebhookHMACEncoding,
|
||||
) -> None:
|
||||
async with hmac_webhook(
|
||||
hatchet, test_run_id, algorithm=algorithm, encoding=encoding
|
||||
) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"HMAC",
|
||||
{
|
||||
"header_name": TEST_HMAC_SIGNATURE_HEADER,
|
||||
"secret": TEST_HMAC_SECRET,
|
||||
"algorithm": algorithm,
|
||||
"encoding": encoding,
|
||||
},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data == {"message": "ok"}
|
||||
|
||||
await assert_has_runs(
|
||||
hatchet,
|
||||
test_start,
|
||||
webhook_body,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret",
|
||||
[
|
||||
"incorrect_secret",
|
||||
"",
|
||||
"partial_secret",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_hmac_signature_failure(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
secret: str,
|
||||
) -> None:
|
||||
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"HMAC",
|
||||
{
|
||||
"header_name": TEST_HMAC_SIGNATURE_HEADER,
|
||||
"secret": secret,
|
||||
"algorithm": V1WebhookHMACAlgorithm.SHA256,
|
||||
"encoding": V1WebhookHMACEncoding.HEX,
|
||||
},
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_hmac_missing_signature_header(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
) -> None:
|
||||
async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"
|
||||
) as response:
|
||||
assert response.status == 403
|
||||
|
||||
await assert_event_not_created(
|
||||
hatchet,
|
||||
test_start,
|
||||
incoming_webhook,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_name",
|
||||
[
|
||||
V1WebhookSourceName.GENERIC,
|
||||
V1WebhookSourceName.GITHUB,
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_different_source_types(
|
||||
hatchet: Hatchet,
|
||||
test_run_id: str,
|
||||
test_start: datetime,
|
||||
webhook_body: WebhookInput,
|
||||
source_name: V1WebhookSourceName,
|
||||
) -> None:
|
||||
async with basic_auth_webhook(
|
||||
hatchet, test_run_id, source_name=source_name
|
||||
) as incoming_webhook:
|
||||
async with await send_webhook_request(
|
||||
url(hatchet.tenant_id, incoming_webhook.name),
|
||||
webhook_body,
|
||||
"BASIC",
|
||||
{"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data == {"message": "ok"}
|
||||
|
||||
await assert_has_runs(
|
||||
hatchet,
|
||||
test_start,
|
||||
webhook_body,
|
||||
incoming_webhook,
|
||||
)
|
||||
27
examples/python/webhooks/worker.py
Normal file
27
examples/python/webhooks/worker.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# > Webhooks
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hatchet_sdk import Context, Hatchet
|
||||
|
||||
hatchet = Hatchet(debug=True)
|
||||
|
||||
|
||||
class WebhookInput(BaseModel):
|
||||
type: str
|
||||
message: str
|
||||
|
||||
|
||||
@hatchet.task(input_validator=WebhookInput, on_events=["webhook:test"])
|
||||
def webhook(input: WebhookInput, ctx: Context) -> dict[str, str]:
|
||||
return input.model_dump()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
worker = hatchet.worker("webhook-worker", workflows=[webhook])
|
||||
worker.start()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -26,6 +26,7 @@ from examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details
|
||||
from examples.return_exceptions.worker import return_exceptions_task
|
||||
from examples.simple.worker import simple, simple_durable
|
||||
from examples.timeout.worker import refresh_timeout_wf, timeout_wf
|
||||
from examples.webhooks.worker import webhook
|
||||
from hatchet_sdk import Hatchet
|
||||
|
||||
hatchet = Hatchet(debug=True)
|
||||
@@ -66,6 +67,7 @@ def main() -> None:
|
||||
bulk_replay_test_1,
|
||||
bulk_replay_test_2,
|
||||
bulk_replay_test_3,
|
||||
webhook,
|
||||
return_exceptions_task,
|
||||
wait_for_sleep_twice,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user