chore: fix ruff lint (webhooks v2) + Windows-safe test teardown
FlowDeck CI / lint (push) Successful in 1m21s
FlowDeck CI / test (push) Failing after 10m1s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-21 21:58:47 -04:00
parent 2fceed0da2
commit b56b181c3e
5 changed files with 40 additions and 44 deletions
+10 -10
View File
@@ -16,7 +16,6 @@ from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from app.db import get_conn from app.db import get_conn
from app.services.automations import fire_event as _fire_event
from app.services.api_v2_helpers import ( from app.services.api_v2_helpers import (
audit_log, audit_log,
check_idempotency, check_idempotency,
@@ -30,6 +29,7 @@ from app.services.api_v2_helpers import (
to_iso8601, to_iso8601,
validate_scopes_input, validate_scopes_input,
) )
from app.services.automations import fire_event as _fire_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2", tags=["api-v2"]) router = APIRouter(prefix="/api/v2", tags=["api-v2"])
@@ -2397,19 +2397,19 @@ async def retry_webhook_deliveries(webhook_id: int, request: Request,
_v2_rate_check(request, user) _v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"): if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write") raise HTTPException(403, "Insufficient scope. Required: write")
from app.services.webhook_outbound import retry_due_deliveries from app.services.webhook_outbound import retry_due_deliveries
with get_conn() as conn: with get_conn() as conn:
# Force retry by setting next_retry_at to the past # Force retry by setting next_retry_at to the past
conn.execute( conn.execute(
"""UPDATE webhook_deliveries """UPDATE webhook_deliveries
SET next_retry_at = strftime('%s', 'now', '-1 second') SET next_retry_at = strftime('%s', 'now', '-1 second')
WHERE webhook_id = ? AND status = 'retrying'""", WHERE webhook_id = ? AND status = 'retrying'""",
(webhook_id,), (webhook_id,),
) )
conn.commit() conn.commit()
retried = await retry_due_deliveries() retried = await retry_due_deliveries()
audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request) audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request)
return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried} return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried}
@@ -2421,19 +2421,19 @@ async def verify_webhook_signature(request: Request,
"""Verify a webhook signature (for debugging/testing).""" """Verify a webhook signature (for debugging/testing)."""
user = get_bearer_user(request, authorization) user = get_bearer_user(request, authorization)
_v2_rate_check(request, user) _v2_rate_check(request, user)
from app.services.webhook_outbound import verify_signature from app.services.webhook_outbound import verify_signature
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
secret = body.get("secret", "") secret = body.get("secret", "")
payload = body.get("payload", "{}") payload = body.get("payload", "{}")
signature = body.get("signature", "") signature = body.get("signature", "")
is_valid = verify_signature(secret, payload.encode(), signature) is_valid = verify_signature(secret, payload.encode(), signature)
audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request) audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request)
return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret} return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret}
+3 -1
View File
@@ -30,7 +30,9 @@ def _user(request: Request, authorization: str | None = None,
try: try:
user = get_bearer_user(request, authorization) user = get_bearer_user(request, authorization)
except HTTPException: except HTTPException:
raise HTTPException(status_code=401, detail="Invalid or expired API token") raise HTTPException(
status_code=401, detail="Invalid or expired API token"
) from None
if not has_scope(user.get("_token_scopes"), required_scope): if not has_scope(user.get("_token_scopes"), required_scope):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
+2 -2
View File
@@ -112,7 +112,7 @@ def _log_delivery(webhook_id: int, event: str, status: str, http_code: int | Non
async def _deliver_once(client: httpx.AsyncClient, url: str, event: str, async def _deliver_once(client: httpx.AsyncClient, url: str, event: str,
payload: dict, secret: str) -> tuple[int | None, str]: payload: dict, secret: str) -> tuple[int | None, str]:
"""Single POST attempt. Returns (http_code, error). """Single POST attempt. Returns (http_code, error).
Includes HMAC-SHA256 signature in X-FlowDeck-Signature header. Includes HMAC-SHA256 signature in X-FlowDeck-Signature header.
""" """
body = json.dumps({"event": event, **payload}).encode() body = json.dumps({"event": event, **payload}).encode()
@@ -144,7 +144,7 @@ async def deliver_to_sub(sub_id: int, url: str, event: str, payload: dict,
Returns True on success. Failures are re-queued via ``next_retry_at`` so Returns True on success. Failures are re-queued via ``next_retry_at`` so
the background scheduler can pick them up even if this process restarts. the background scheduler can pick them up even if this process restarts.
Retry schedule: 2s, 10s, 60s (3 retries total + initial attempt). Retry schedule: 2s, 10s, 60s (3 retries total + initial attempt).
""" """
own_client = _client is None own_client = _client is None
+4 -1
View File
@@ -42,7 +42,10 @@ def client():
yield TestClient(app) yield TestClient(app)
os.unlink(db_path) try:
os.unlink(db_path)
except PermissionError:
pass # Windows: file may still be open in another thread
# ── Core ── # ── Core ──
+21 -30
View File
@@ -1,12 +1,7 @@
"""FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events).""" """FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events)."""
from __future__ import annotations from __future__ import annotations
import asyncio from unittest.mock import AsyncMock, MagicMock
import hashlib
import hmac
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
import pytest import pytest
@@ -17,15 +12,11 @@ from app.services.webhook_outbound import (
RETRY_DELAYS, RETRY_DELAYS,
_deliver_once, _deliver_once,
_event_matches, _event_matches,
deliver_to_sub,
fire_event,
init_webhook_tables, init_webhook_tables,
retry_due_deliveries,
sign_payload, sign_payload,
verify_signature, verify_signature,
) )
# ── Fixtures ──────────────────────────────────────────────────────────────── # ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
@@ -53,7 +44,7 @@ def test_sign_payload_returns_sha256_hex():
secret = "my_secret" secret = "my_secret"
body = b'{"event": "page.created", "data": {}}' body = b'{"event": "page.created", "data": {}}'
signature = sign_payload(secret, body) signature = sign_payload(secret, body)
assert signature.startswith("sha256=") assert signature.startswith("sha256=")
hex_part = signature[7:] # Remove 'sha256=' prefix hex_part = signature[7:] # Remove 'sha256=' prefix
assert len(hex_part) == 64 # SHA-256 hex is 64 chars assert len(hex_part) == 64 # SHA-256 hex is 64 chars
@@ -63,10 +54,10 @@ def test_sign_payload_consistency():
"""Test that the same input produces the same signature.""" """Test that the same input produces the same signature."""
secret = "my_secret" secret = "my_secret"
body = b'{"event": "page.created"}' body = b'{"event": "page.created"}'
sig1 = sign_payload(secret, body) sig1 = sign_payload(secret, body)
sig2 = sign_payload(secret, body) sig2 = sign_payload(secret, body)
assert sig1 == sig2 assert sig1 == sig2
@@ -75,7 +66,7 @@ def test_verify_signature_valid():
secret = "my_secret" secret = "my_secret"
body = b'{"event": "page.created", "data": {}}' body = b'{"event": "page.created", "data": {}}'
signature = sign_payload(secret, body) signature = sign_payload(secret, body)
assert verify_signature(secret, body, signature) is True assert verify_signature(secret, body, signature) is True
@@ -85,7 +76,7 @@ def test_verify_signature_invalid_secret():
wrong_secret = "wrong_secret" wrong_secret = "wrong_secret"
body = b'{"event": "page.created"}' body = b'{"event": "page.created"}'
signature = sign_payload(secret, body) signature = sign_payload(secret, body)
assert verify_signature(wrong_secret, body, signature) is False assert verify_signature(wrong_secret, body, signature) is False
@@ -94,7 +85,7 @@ def test_verify_signature_invalid_signature():
secret = "my_secret" secret = "my_secret"
body = b'{"event": "page.created"}' body = b'{"event": "page.created"}'
signature = "sha256=invalid_hex_value" signature = "sha256=invalid_hex_value"
assert verify_signature(secret, body, signature) is False assert verify_signature(secret, body, signature) is False
@@ -102,7 +93,7 @@ def test_verify_signature_empty_secret():
"""Test that verify_signature returns False for empty secret.""" """Test that verify_signature returns False for empty secret."""
body = b'{"event": "page.created"}' body = b'{"event": "page.created"}'
signature = "sha256=somehash" signature = "sha256=somehash"
assert verify_signature("", body, signature) is False assert verify_signature("", body, signature) is False
@@ -110,7 +101,7 @@ def test_verify_signature_empty_signature():
"""Test that verify_signature returns False for empty signature.""" """Test that verify_signature returns False for empty signature."""
secret = "my_secret" secret = "my_secret"
body = b'{"event": "page.created"}' body = b'{"event": "page.created"}'
assert verify_signature(secret, body, "") is False assert verify_signature(secret, body, "") is False
@@ -220,14 +211,14 @@ async def test_deliver_once_success():
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_client.post.return_value = mock_response mock_client.post.return_value = mock_response
url = "https://example.com/webhook" url = "https://example.com/webhook"
event = "page.created" event = "page.created"
payload = {"page_id": 123} payload = {"page_id": 123}
secret = "test_secret" secret = "test_secret"
code, error = await _deliver_once(mock_client, url, event, payload, secret) code, error = await _deliver_once(mock_client, url, event, payload, secret)
assert code == 200 assert code == 200
assert error == "" assert error == ""
mock_client.post.assert_called_once() mock_client.post.assert_called_once()
@@ -243,14 +234,14 @@ async def test_deliver_once_http_error():
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 500 mock_response.status_code = 500
mock_client.post.return_value = mock_response mock_client.post.return_value = mock_response
url = "https://example.com/webhook" url = "https://example.com/webhook"
event = "page.created" event = "page.created"
payload = {} payload = {}
secret = "test_secret" secret = "test_secret"
code, error = await _deliver_once(mock_client, url, event, payload, secret) code, error = await _deliver_once(mock_client, url, event, payload, secret)
assert code == 500 assert code == 500
assert "HTTP 500" in error assert "HTTP 500" in error
@@ -260,14 +251,14 @@ async def test_deliver_once_timeout():
"""Test _deliver_once handles timeout errors.""" """Test _deliver_once handles timeout errors."""
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.post.side_effect = httpx.TimeoutException("Timeout after 30s") mock_client.post.side_effect = httpx.TimeoutException("Timeout after 30s")
url = "https://example.com/webhook" url = "https://example.com/webhook"
event = "page.created" event = "page.created"
payload = {} payload = {}
secret = "test_secret" secret = "test_secret"
code, error = await _deliver_once(mock_client, url, event, payload, secret) code, error = await _deliver_once(mock_client, url, event, payload, secret)
assert code is None assert code is None
assert "Timeout" in error assert "Timeout" in error
@@ -277,14 +268,14 @@ async def test_deliver_once_request_error():
"""Test _deliver_once handles request errors.""" """Test _deliver_once handles request errors."""
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.post.side_effect = httpx.RequestError("Connection refused") mock_client.post.side_effect = httpx.RequestError("Connection refused")
url = "https://example.com/webhook" url = "https://example.com/webhook"
event = "page.created" event = "page.created"
payload = {} payload = {}
secret = "test_secret" secret = "test_secret"
code, error = await _deliver_once(mock_client, url, event, payload, secret) code, error = await _deliver_once(mock_client, url, event, payload, secret)
assert code is None assert code is None
assert "Request error" in error assert "Request error" in error