From b56b181c3ee987124c6cedb54e443b201703f432 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 21 Sep 2026 21:58:47 -0400 Subject: [PATCH] chore: fix ruff lint (webhooks v2) + Windows-safe test teardown --- app/routers/api_v2.py | 20 ++++++------- app/routers/sync.py | 4 ++- app/services/webhook_outbound.py | 4 +-- tests/test_app.py | 5 +++- tests/test_webhooks_v2.py | 51 +++++++++++++------------------- 5 files changed, 40 insertions(+), 44 deletions(-) diff --git a/app/routers/api_v2.py b/app/routers/api_v2.py index c6276e1..35ffd27 100644 --- a/app/routers/api_v2.py +++ b/app/routers/api_v2.py @@ -16,7 +16,6 @@ from fastapi import APIRouter, Header, HTTPException, Request from fastapi.responses import JSONResponse, Response from app.db import get_conn -from app.services.automations import fire_event as _fire_event from app.services.api_v2_helpers import ( audit_log, check_idempotency, @@ -30,6 +29,7 @@ from app.services.api_v2_helpers import ( to_iso8601, validate_scopes_input, ) +from app.services.automations import fire_event as _fire_event logger = logging.getLogger(__name__) 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) if not has_scope(user.get("_token_scopes"), "write"): raise HTTPException(403, "Insufficient scope. Required: write") - + from app.services.webhook_outbound import retry_due_deliveries - + with get_conn() as conn: # Force retry by setting next_retry_at to the past conn.execute( - """UPDATE webhook_deliveries + """UPDATE webhook_deliveries SET next_retry_at = strftime('%s', 'now', '-1 second') WHERE webhook_id = ? AND status = 'retrying'""", (webhook_id,), ) conn.commit() - + retried = await retry_due_deliveries() audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request) 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).""" user = get_bearer_user(request, authorization) _v2_rate_check(request, user) - + from app.services.webhook_outbound import verify_signature - + try: body = await request.json() except Exception: body = {} - + secret = body.get("secret", "") payload = body.get("payload", "{}") signature = body.get("signature", "") - + is_valid = verify_signature(secret, payload.encode(), signature) - + 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} diff --git a/app/routers/sync.py b/app/routers/sync.py index 285edd8..0c7b9e3 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -30,7 +30,9 @@ def _user(request: Request, authorization: str | None = None, try: user = get_bearer_user(request, authorization) 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): raise HTTPException( status_code=403, diff --git a/app/services/webhook_outbound.py b/app/services/webhook_outbound.py index 4dd5b9d..abefda9 100644 --- a/app/services/webhook_outbound.py +++ b/app/services/webhook_outbound.py @@ -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, payload: dict, secret: str) -> tuple[int | None, str]: """Single POST attempt. Returns (http_code, error). - + Includes HMAC-SHA256 signature in X-FlowDeck-Signature header. """ 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 the background scheduler can pick them up even if this process restarts. - + Retry schedule: 2s, 10s, 60s (3 retries total + initial attempt). """ own_client = _client is None diff --git a/tests/test_app.py b/tests/test_app.py index 7449cde..9ef8638 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -42,7 +42,10 @@ def client(): 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 ── diff --git a/tests/test_webhooks_v2.py b/tests/test_webhooks_v2.py index 33d966b..0698ce1 100644 --- a/tests/test_webhooks_v2.py +++ b/tests/test_webhooks_v2.py @@ -1,12 +1,7 @@ """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 +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -17,15 +12,11 @@ from app.services.webhook_outbound import ( RETRY_DELAYS, _deliver_once, _event_matches, - deliver_to_sub, - fire_event, init_webhook_tables, - retry_due_deliveries, sign_payload, verify_signature, ) - # ── Fixtures ──────────────────────────────────────────────────────────────── @pytest.fixture @@ -53,7 +44,7 @@ def test_sign_payload_returns_sha256_hex(): 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 @@ -63,10 +54,10 @@ 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 @@ -75,7 +66,7 @@ def test_verify_signature_valid(): secret = "my_secret" body = b'{"event": "page.created", "data": {}}' signature = sign_payload(secret, body) - + assert verify_signature(secret, body, signature) is True @@ -85,7 +76,7 @@ def test_verify_signature_invalid_secret(): wrong_secret = "wrong_secret" body = b'{"event": "page.created"}' signature = sign_payload(secret, body) - + assert verify_signature(wrong_secret, body, signature) is False @@ -94,7 +85,7 @@ def test_verify_signature_invalid_signature(): secret = "my_secret" body = b'{"event": "page.created"}' signature = "sha256=invalid_hex_value" - + 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.""" body = b'{"event": "page.created"}' signature = "sha256=somehash" - + 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.""" secret = "my_secret" body = b'{"event": "page.created"}' - + assert verify_signature(secret, body, "") is False @@ -220,14 +211,14 @@ async def test_deliver_once_success(): 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() @@ -243,14 +234,14 @@ async def test_deliver_once_http_error(): 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 @@ -260,14 +251,14 @@ 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 @@ -277,14 +268,14 @@ 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