Compare commits

...
Author SHA1 Message Date
bruno 6dfd6d718e feat: v6.5.1 — 7 tests webhooks_v2 dé-skipés (0 skip, 749 verts) + roadmap rattrapée (sync.py Bearer coché)
FlowDeck CI / docker (push) Successful in 1m20s
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m59s
2026-09-24 09:10:08 -04:00
bruno 401d0b17ca merge: feat/v6.5.0-synced-db → main (v6.5.0 Synced blocks production)
FlowDeck CI / docker (push) Successful in 1m21s
FlowDeck CI / lint (push) Successful in 1m28s
FlowDeck CI / test (push) Successful in 11m0s
2026-09-24 08:28:33 -04:00
5 changed files with 245 additions and 38 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v6.5.1 (2026-09-24) — Tests webhooks_v2 complets + roadmap rattrapée
> Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis
> v5.15.0) sont désormais réellement câblés, et le ROADMAP rattrapé sur ce
> qui avait été livré entre-temps.
### Added
- **7 tests d'intégration webhooks** (`tests/test_webhooks_v2.py`) : retry échec→succès (journal `retrying→retrying→delivered`), échec après `MAX_ATTEMPTS` (4 POST + `failed` final), `fire_event` ignore les events inconnus, aucun abonné → aucun appel, fan-out wildcard (`page.*` reçoit, `collection.created` non), flow complet signé bout en bout (HMAC vérifié sur le body reçu + journal `delivered`), `retry_due_deliveries` (row `retrying` périmée → re-fire → `delivered`, original `superseded`)
- **Fixture `db` isolée** — SQLite temporaire par test (env + singleton `settings` restaurés, safe `-n auto`), tables webhook créées par `init_db()` ; `httpx.MockTransport` injecté via stub module + `RETRY_DELAYS=(0,0,0)` (zéro sleep réel)
### Changed
- **ROADMAP** — case « migration `sync.py` vers Bearer » cochée (livrée v6.4.0 : Bearer-first + repli session PWA) ; ligne v5.15.0 mise à jour ; résumé final → « Reste: SSO/SAML » ; **suite 749 verts, 0 skip**
### Tests
- `pytest -n auto` → **749 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues) ## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
> Les synced blocks passent en production et existent enfin dans les databases : > Les synced blocks passent en production et existent enfin dans les databases :
+3 -3
View File
@@ -702,7 +702,7 @@ Détails livrés :
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés - [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.) - [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs) - [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration) - [x] **Tests** — 21 dédiés (`tests/test_webhooks_v2.py`) ; **7 tests d'intégration câblés** (retry échec→succès, échec après MAX_ATTEMPTS, event inconnu ignoré, aucun abonné, fan-out wildcard, flow complet signé bout en bout, `retry_due_deliveries` superseded→delivered) — fixture `db` = SQLite isolée par test + `httpx.MockTransport` + `RETRY_DELAYS` neutralisé ; suite complète **749 verts, 0 skip**
--- ---
@@ -806,7 +806,7 @@ Détails livrés :
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin - [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`) - [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt) - [x] *(reporté, livré v6.4.0)* : migration de `sync.py` vers Bearer — auth `Authorization: Bearer` (scopes read/write) via `get_bearer_user()` + repli cookie session conservé pour compat PWA offline (`app/routers/sync.py`)
#### Phase 8 — OpenAPI, tests & docs ✅ #### Phase 8 — OpenAPI, tests & docs ✅
@@ -932,4 +932,4 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.* *Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés) + **v6.4.0** realtime + **v6.3.0** API v2. Aussi: case `sync.py` → Bearer cochée (livrée v6.4.0) + 7 tests webhooks_v2 dé-skipés → **suite 749 verts, 0 skip**. Reste: **SSO/SAML**.*
+1 -1
View File
@@ -1 +1 @@
6.5.0 6.5.1
+1 -1
View File
@@ -122,7 +122,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="6.5.0", version="6.5.1",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+221 -33
View File
@@ -1,18 +1,26 @@
"""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 json
import os
import tempfile
import time
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx import httpx
import pytest import pytest
from app.db import get_conn, init_db
from app.services import webhook_outbound
from app.services.webhook_outbound import ( from app.services.webhook_outbound import (
EVENTS, EVENTS,
MAX_ATTEMPTS, MAX_ATTEMPTS,
RETRY_DELAYS, RETRY_DELAYS,
_deliver_once, _deliver_once,
_event_matches, _event_matches,
init_webhook_tables, deliver_to_sub,
fire_event,
retry_due_deliveries,
sign_payload, sign_payload,
verify_signature, verify_signature,
) )
@@ -21,9 +29,38 @@ from app.services.webhook_outbound import (
@pytest.fixture @pytest.fixture
def db(): def db():
"""Ensure webhook tables exist.""" """Fresh isolated SQLite database with the full FlowDeck schema.
init_webhook_tables()
return True Creates ``webhook_subscriptions`` + ``webhook_deliveries`` (versioned
migrations) so delivery journal rows can be asserted, then restores the
previous settings — safe under ``pytest -n auto``.
"""
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = tmp.name
tmp.close()
prev_env = os.environ.get("DATABASE_URL")
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
import app.config
settings_obj = app.config.settings
prev_url = settings_obj.database_url
settings_obj.database_url = f"sqlite:///{db_path}"
init_db()
yield
settings_obj.database_url = prev_url
if prev_env is None:
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = prev_env
for suffix in ("", "-wal", "-shm"):
try:
os.unlink(db_path + suffix)
except (PermissionError, OSError):
pass
@pytest.fixture @pytest.fixture
@@ -280,53 +317,204 @@ async def test_deliver_once_request_error():
assert "Request error" in error assert "Request error" in error
# ── Integration Tests (Skipped if dependencies are missing) ──────────────────── # ── Integration Tests (isolated DB + mocked HTTP) ───────────────────────────
def _add_sub(url: str = "https://receiver.example/hook", event: str = "*",
secret: str = "test_secret") -> int:
"""Insert an active subscription and return its id."""
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret, active) VALUES (?, ?, ?, 1)",
(url, event, secret),
)
conn.commit()
return cur.lastrowid
def _journal() -> list[dict]:
"""All delivery-journal rows, oldest first."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM webhook_deliveries ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def _patch_async_client(monkeypatch, handler) -> None:
"""Route every ``httpx.AsyncClient`` built by webhook_outbound to a MockTransport."""
real_client = httpx.AsyncClient
transport = httpx.MockTransport(handler)
def factory(*args, **kwargs):
kwargs.pop("transport", None)
return real_client(transport=transport, **kwargs)
from types import SimpleNamespace
stub = SimpleNamespace(
AsyncClient=factory,
TimeoutException=httpx.TimeoutException,
RequestError=httpx.RequestError,
)
monkeypatch.setattr(webhook_outbound, "httpx", stub)
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliver_to_sub_retries_on_failure(): async def test_deliver_to_sub_retries_on_failure(db, monkeypatch):
"""Test that deliver_to_sub retries on failure.""" """Two failures then success → 3 attempts, journal retrying→retrying→delivered."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0)) # no real sleeps
sub_id = _add_sub(url="https://receiver.example/retry", event="page.created")
mock_client = AsyncMock()
mock_client.post.side_effect = [
MagicMock(status_code=500),
MagicMock(status_code=500),
MagicMock(status_code=200),
]
ok = await deliver_to_sub(
sub_id, "https://receiver.example/retry", "page.created",
{"page_id": 1}, "test_secret", _client=mock_client,
)
assert ok is True
assert mock_client.post.call_count == 3
assert [r["status"] for r in _journal()] == ["retrying", "retrying", "delivered"]
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliver_to_sub_fails_after_max_attempts(): async def test_deliver_to_sub_fails_after_max_attempts(db, monkeypatch):
"""Test that deliver_to_sub fails after MAX_ATTEMPTS.""" """Persistent 500 → exactly MAX_ATTEMPTS attempts, final journal row 'failed'."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
sub_id = _add_sub(url="https://receiver.example/down")
mock_client = AsyncMock()
mock_client.post.return_value = MagicMock(status_code=500)
ok = await deliver_to_sub(
sub_id, "https://receiver.example/down", "page.created",
{}, "test_secret", _client=mock_client,
)
assert ok is False
assert mock_client.post.call_count == MAX_ATTEMPTS
journal = _journal()
assert len(journal) == MAX_ATTEMPTS # 3 'retrying' + 1 'failed'
assert journal[-1]["status"] == "failed"
assert journal[-1]["http_code"] == 500
assert journal[-1]["attempt"] == MAX_ATTEMPTS - 1
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_ignores_unknown_events(): async def test_fire_event_ignores_unknown_events(db, monkeypatch):
"""Test that fire_event ignores unknown events.""" """fire_event returns before touching subscribers/HTTP for unknown events."""
pass delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
_add_sub(url="https://receiver.example/any", event="*")
await fire_event("nope.not_an_event", {"x": 1})
delivered.assert_not_awaited()
assert _journal() == []
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_no_subscribers(): async def test_fire_event_no_subscribers(db, monkeypatch):
"""Test that fire_event does nothing if no subscribers.""" """Valid event but empty webhook_subscriptions → no delivery attempt."""
pass delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
await fire_event("page.created", {"page_id": 1})
delivered.assert_not_awaited()
assert _journal() == []
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_delivers_to_subscribers(): async def test_fire_event_delivers_to_subscribers(db, monkeypatch):
"""Test that fire_event delivers to matching subscribers.""" """Wildcard fan-out: only the subscription matching page.* receives the event."""
pass matching = _add_sub(url="https://receiver.example/pages", event="page.*")
_add_sub(url="https://receiver.example/collections", event="collection.created")
delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
await fire_event("page.created", {"page_id": 42})
delivered.assert_awaited_once()
args = delivered.await_args.args
assert args[0] == matching
assert args[1] == "https://receiver.example/pages"
assert args[2] == "page.created"
assert args[3] == {"page_id": 42}
assert args[4] == "test_secret"
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_webhook_flow(): async def test_full_webhook_flow(db, monkeypatch):
"""Test a full webhook flow: event fired → delivered to subscriber.""" """End-to-end: subscription → fire_event → signed HTTP POST → 'delivered' journal."""
pass secret = "flow_secret_abc"
sub_id = _add_sub(url="https://receiver.example/hook", event="page.*", secret=secret)
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["headers"] = dict(request.headers)
seen["body"] = bytes(request.content)
return httpx.Response(200, json={"ok": True})
_patch_async_client(monkeypatch, handler)
await fire_event("page.created", {"page_id": 7, "title": "Hello"})
# HTTP assertions: right URL, event header, verifiable HMAC over the raw body
assert seen["url"] == "https://receiver.example/hook"
assert seen["headers"]["x-flowdeck-event"] == "page.created"
assert verify_signature(secret, seen["body"],
seen["headers"]["x-flowdeck-signature"]) is True
payload = json.loads(seen["body"])
assert payload["event"] == "page.created"
assert payload["page_id"] == 7
assert payload["title"] == "Hello"
# Journal assertions
journal = _journal()
assert len(journal) == 1
assert journal[0]["webhook_id"] == sub_id
assert journal[0]["status"] == "delivered"
assert journal[0]["http_code"] == 200
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_due_deliveries(): async def test_retry_due_deliveries(db, monkeypatch):
"""Test retry_due_deliveries retries failed deliveries.""" """A 'retrying' row past next_retry_at is re-fired, delivered, then superseded."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
sub_id = _add_sub(url="https://receiver.example/retry-due", event="page.*")
with get_conn() as conn:
conn.execute(
"""INSERT INTO webhook_deliveries
(webhook_id, event, status, http_code, error, duration_ms,
attempt, payload, next_retry_at)
VALUES (?, 'page.created', 'retrying', 500, 'HTTP 500', 12, 0, ?, ?)""",
(sub_id, json.dumps({"page_id": 9}), time.time() - 1),
)
conn.commit()
hits: list = []
def handler(request: httpx.Request) -> httpx.Response:
hits.append(request)
return httpx.Response(200)
_patch_async_client(monkeypatch, handler)
retried = await retry_due_deliveries(now=time.time())
assert retried == 1
assert len(hits) == 1
assert hits[0].headers["x-flowdeck-event"] == "page.created"
journal = _journal()
assert len(journal) == 2
assert journal[0]["status"] == "superseded" # original due row
assert journal[1]["status"] == "delivered" # new attempt
assert journal[1]["http_code"] == 200