"""FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events).""" from __future__ import annotations import asyncio import hashlib import hmac import json import time from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from app.services.webhook_outbound import ( EVENTS, MAX_ATTEMPTS, RETRY_DELAYS, _deliver_once, _event_matches, deliver_to_sub, fire_event, init_webhook_tables, retry_due_deliveries, sign_payload, verify_signature, ) # ── Fixtures ──────────────────────────────────────────────────────────────── @pytest.fixture def db(): """Ensure webhook tables exist.""" init_webhook_tables() return True @pytest.fixture def sample_payload(): return {"page_id": 123, "title": "Test Page", "user_id": 456} @pytest.fixture def sample_secret(): return "test_webhook_secret_12345" # ── HMAC Signature Tests ──────────────────────────────────────────────────── def test_sign_payload_returns_sha256_hex(): """Test that sign_payload returns a valid HMAC-SHA256 signature.""" secret = "my_secret" body = b'{"event": "page.created", "data": {}}' signature = sign_payload(secret, body) assert signature.startswith("sha256=") hex_part = signature[7:] # Remove 'sha256=' prefix assert len(hex_part) == 64 # SHA-256 hex is 64 chars def test_sign_payload_consistency(): """Test that the same input produces the same signature.""" secret = "my_secret" body = b'{"event": "page.created"}' sig1 = sign_payload(secret, body) sig2 = sign_payload(secret, body) assert sig1 == sig2 def test_verify_signature_valid(): """Test that verify_signature returns True for valid signatures.""" secret = "my_secret" body = b'{"event": "page.created", "data": {}}' signature = sign_payload(secret, body) assert verify_signature(secret, body, signature) is True def test_verify_signature_invalid_secret(): """Test that verify_signature returns False for wrong secret.""" secret = "my_secret" wrong_secret = "wrong_secret" body = b'{"event": "page.created"}' signature = sign_payload(secret, body) assert verify_signature(wrong_secret, body, signature) is False def test_verify_signature_invalid_signature(): """Test that verify_signature returns False for tampered signature.""" secret = "my_secret" body = b'{"event": "page.created"}' signature = "sha256=invalid_hex_value" assert verify_signature(secret, body, signature) is False def test_verify_signature_empty_secret(): """Test that verify_signature returns False for empty secret.""" body = b'{"event": "page.created"}' signature = "sha256=somehash" assert verify_signature("", body, signature) is False def test_verify_signature_empty_signature(): """Test that verify_signature returns False for empty signature.""" secret = "my_secret" body = b'{"event": "page.created"}' assert verify_signature(secret, body, "") is False def test_verify_signature_none_values(): """Test that verify_signature handles None values.""" assert verify_signature(None, b'{}', "sha256=abc") is False assert verify_signature("secret", None, "sha256=abc") is False assert verify_signature("secret", b'{}', None) is False # ── Event Matching Tests ──────────────────────────────────────────────────── def test_event_matches_exact(): """Test exact event matching.""" assert _event_matches("page.created", "page.created") is True assert _event_matches("page.created", "page.updated") is False def test_event_matches_wildcard_all(): """Test wildcard '*' matches all events.""" assert _event_matches("*", "page.created") is True assert _event_matches("*", "collection.deleted") is True assert _event_matches("all", "agent.run.finished") is True def test_event_matches_wildcard_prefix(): """Test wildcard prefix matching (e.g., 'page.*').""" assert _event_matches("page.*", "page.created") is True assert _event_matches("page.*", "page.updated") is True assert _event_matches("page.*", "page.deleted") is True assert _event_matches("page.*", "collection.created") is False def test_event_matches_wildcard_suffix(): """Test that suffix wildcards are not supported (only prefix).""" assert _event_matches("*.created", "page.created") is False assert _event_matches("*.created", "collection.created") is False # ── Event Catalogue Tests ──────────────────────────────────────────────────── def test_events_list_has_50_plus_events(): """Test that EVENTS list has at least 50 events.""" assert len(EVENTS) >= 50, f"Expected at least 50 events, got {len(EVENTS)}" def test_events_list_contains_new_events(): """Test that new events are included in the catalogue.""" new_events = [ "page.renamed", "page.duplicated", "page.archived", "comment.updated", "comment.deleted", "mention.resolved", "collection.renamed", "collection.duplicated", "collection.page.moved", "collection.view.updated", "collection.view.deleted", "collection.property.created", "collection.property.updated", "collection.property.deleted", "sprint.deleted", "sprint.started", "sprint.completed", "sprint.canceled", "share.created", "share.updated", "share.revoked", "automation.failed", "automation.retrying", "agent.run.started", "agent.run.failed", "workspace.created", "workspace.updated", "workspace.deleted", "workspace.member.added", "workspace.member.removed", "workspace.member.role_changed", "file.uploaded", "file.deleted", "import.started", "import.completed", "import.failed", ] for event in new_events: assert event in EVENTS, f"Event '{event}' not found in EVENTS list" def test_events_list_contains_legacy_events(): """Test that legacy events are still present.""" legacy_events = [ "page.created", "page.updated", "page.deleted", "page.moved", "comment.added", "comment.resolved", "collection.created", "collection.updated", "collection.deleted", "sprint.created", "sprint.updated", "favorite.added", "favorite.removed", "automation.fired", "agent.run.finished", "ping", ] for event in legacy_events: assert event in EVENTS, f"Legacy event '{event}' missing from EVENTS list" # ── Retry Schedule Tests ──────────────────────────────────────────────────── def test_retry_delays_are_2_10_60(): """Test that retry delays are 2s, 10s, 60s.""" assert RETRY_DELAYS == (2, 10, 60) def test_max_attempts_is_4(): """Test that MAX_ATTEMPTS is 4 (1 initial + 3 retries).""" assert MAX_ATTEMPTS == 4 # ── Delivery Tests (Mocked) ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_deliver_once_success(): """Test successful delivery in _deliver_once.""" mock_client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 mock_client.post.return_value = mock_response url = "https://example.com/webhook" event = "page.created" payload = {"page_id": 123} secret = "test_secret" code, error = await _deliver_once(mock_client, url, event, payload, secret) assert code == 200 assert error == "" mock_client.post.assert_called_once() call_args = mock_client.post.call_args assert call_args.kwargs["headers"]["X-FlowDeck-Event"] == event assert call_args.kwargs["headers"]["X-FlowDeck-Signature"].startswith("sha256=") @pytest.mark.asyncio async def test_deliver_once_http_error(): """Test _deliver_once handles HTTP errors.""" mock_client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 500 mock_client.post.return_value = mock_response url = "https://example.com/webhook" event = "page.created" payload = {} secret = "test_secret" code, error = await _deliver_once(mock_client, url, event, payload, secret) assert code == 500 assert "HTTP 500" in error @pytest.mark.asyncio async def test_deliver_once_timeout(): """Test _deliver_once handles timeout errors.""" mock_client = AsyncMock() mock_client.post.side_effect = httpx.TimeoutException("Timeout after 30s") url = "https://example.com/webhook" event = "page.created" payload = {} secret = "test_secret" code, error = await _deliver_once(mock_client, url, event, payload, secret) assert code is None assert "Timeout" in error @pytest.mark.asyncio async def test_deliver_once_request_error(): """Test _deliver_once handles request errors.""" mock_client = AsyncMock() mock_client.post.side_effect = httpx.RequestError("Connection refused") url = "https://example.com/webhook" event = "page.created" payload = {} secret = "test_secret" code, error = await _deliver_once(mock_client, url, event, payload, secret) assert code is None assert "Request error" in error # ── Integration Tests (Skipped if dependencies are missing) ──────────────────── @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_deliver_to_sub_retries_on_failure(): """Test that deliver_to_sub retries on failure.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_deliver_to_sub_fails_after_max_attempts(): """Test that deliver_to_sub fails after MAX_ATTEMPTS.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_fire_event_ignores_unknown_events(): """Test that fire_event ignores unknown events.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_fire_event_no_subscribers(): """Test that fire_event does nothing if no subscribers.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_fire_event_delivers_to_subscribers(): """Test that fire_event delivers to matching subscribers.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_full_webhook_flow(): """Test a full webhook flow: event fired → delivered to subscriber.""" pass @pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config") @pytest.mark.asyncio async def test_retry_due_deliveries(): """Test retry_due_deliveries retries failed deliveries.""" pass