From f706424f90b59bac668e015cc4071af264b1888d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 10:13:23 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20A31=20=E2=80=94=20transaction=20par=20mi?= =?UTF-8?q?gration=20+=20helper=20columns()=20(v7.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` → commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit (isolation_level legacy) : un échec au milieu laissait un schéma partiel commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué. Si une transaction englobante subsiste (init_db commit juste avant), on la vide d'abord plutôt que de l'englober. - Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon) : 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row). `table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set. - Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent. tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque de version), test_columns_helper_validates_table_name suite **1036/1036** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.6.0 docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour --- CHANGELOG.md | 21 +++++++++ ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/migrations.py | 89 ++++++++++++++++++++++++------------ docs/openapi-v2.json | 2 +- tests/test_audit_p0_fixes.py | 52 +++++++++++++++++++++ 8 files changed, 140 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4586da2..8dc6221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog - FlowDeck +## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations) + +### Fixed + +- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)` + → marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le + DDL sortait en autocommit (isolation_level legacy) : un échec au milieu + laissait un schéma partiel commité SANS ligne de version, et la reprise + rejouait un DDL déjà appliqué +- **A31** — helper unique `columns(conn, table)` (valide l'identifiant, + `ValueError` sinon) : **25 copies** de + `{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans + `migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non + livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire + se lit dans le set + +### Tests + +- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque + de version, chemin nominal enregistré), `test_columns_helper_validates_table_name` + ## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 71452b5..112f8e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1151,7 +1151,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* -- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.* +- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**. - [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.* - [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.* - [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.* @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. diff --git a/VERSION b/VERSION index 18bb418..93c8dda 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.5.0 +7.6.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index d22aa98..a9c392f 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.5.0 (audit — A29/A42 partiel) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.6.0 (audit — A31) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index 55e6fdd..9f414ac 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.5.0", + version="7.6.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 87e04bb..a31f524 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None: ) +def columns(conn: sqlite3.Connection, table: str) -> set[str]: + """Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies + de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`. + + ``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas + livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle + unitaire se lit déjà dans le set. + """ + if not table.replace("_", "").isalnum(): + raise ValueError(f"nom de table invalide: {table!r}") + return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + def current_version(conn: sqlite3.Connection) -> int: _ensure_table(conn) row = conn.execute( @@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int: for version, name, fn in MIGRATIONS: if version <= applied: continue + _apply_one(conn, version, name, fn) + applied = version + logger.info("Applied migration %d: %s", version, name) + + return applied + + +def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None: + """A31 : une migration = une transaction (DDL tout-ou-rien). + + Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au + milieu laissait un schéma partiel commité ET pas de ligne schema_version : + la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite, + rollback complet à l'échec, donc la prochaine exécution retente proprement. + """ + if conn.in_transaction: + # transaction résiduelle du caller (init_db commit juste avant) — on + # part d'un état propre plutôt que d'englober son travail. + conn.commit() + conn.execute("BEGIN") + try: fn(conn) conn.execute( "INSERT INTO schema_version (version, name) VALUES (?, ?)", (version, name), ) conn.commit() - applied = version - logger.info("Applied migration %d: %s", version, name) - - return applied + except BaseException: + conn.rollback() + raise # ═══════════════════════════════════════════════════════════════════════════ @@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None: ``projects`` — normalized project list across forges (builtin/gitea/ github) + last sync timestamp for the periodic cron. """ - _pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()} + _pcols = columns(conn, "api_tokens") if "id" not in _pcols: conn.execute( """ @@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)" ) - _scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()} + _scols = columns(conn, "user_sessions") if "id" not in _scols: conn.execute( """ @@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)" ) - _projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()} + _projcols = columns(conn, "projects") if "id" not in _projcols: conn.execute( """ @@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)" ) - _pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + _pcols = columns(conn, "pages") if "cover_url" not in _pcols: conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''") if "page_icon" not in _pcols: @@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None: def _migration_db_templates_validation(conn: sqlite3.Connection) -> None: """v5.3.0: database templates get an icon, properties a validation config, and the built-in database templates are seeded (idempotently).""" - _cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()} + _cols = columns(conn, "database_templates") if "icon" not in _cols: conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'") - _pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()} + _pcols = columns(conn, "collection_properties") if "validation_json" not in _pcols: conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'") @@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None: ``collection_pages.cover_url`` — per-row cover image (gallery/board cards), independent from the block-page ``pages.cover_url``. """ - _pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()} + _pcols = columns(conn, "collection_properties") if "group_name" not in _pcols: conn.execute( "ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''" ) - _vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()} + _vcols = columns(conn, "collection_views") if "created_by" not in _vcols: conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER") if "updated_at" not in _vcols: conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP") - _cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()} + _cpcols = columns(conn, "collection_pages") if "cover_url" not in _cpcols: conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''") @@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)" ) - _ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()} + _ucols = columns(conn, "users") if "timezone" not in _ucols: conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''") @@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None: ``page_global_templates`` — user-created global page templates (blocks_json = same format as the block editor saves). """ - _pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + _pcols = columns(conn, "pages") if "is_locked" not in _pcols: conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0") if "locked_by" not in _pcols: @@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None: ) for table in ("pages", "collection_pages"): - cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + cols = columns(conn, table) if "permission_type" not in cols: conn.execute( f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'" ) - _ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()} + _ccols = columns(conn, "collections") if "permission_type" not in _ccols: conn.execute( "ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'" @@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None: def _add_sync_version(conn: sqlite3.Connection, table: str) -> None: """Add ``sync_version`` to ``table`` if it is not already present.""" - cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + cols = columns(conn, table) if "sync_version" not in cols: conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1") @@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None: ``idempotency_keys`` — Idempotency-Key support for POST creations. """ # api_tokens extra columns - _cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()} + _cols = columns(conn, "api_tokens") if "scopes" not in _cols: conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'") if "expires_at" not in _cols: @@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None: New statuses: ``retrying`` (a later attempt is scheduled) and ``superseded`` (a retry row replaced this attempt). """ - _cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()} + _cols = columns(conn, "webhook_deliveries") if "event" not in _cols: conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''") if "next_retry_at" not in _cols: @@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None: ``ON DELETE CASCADE``: deleting a database row deletes its content page (and ``page_synced_blocks`` cascades from ``pages``). """ - cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + cols = columns(conn, "pages") if "collection_row_id" not in cols: conn.execute( "ALTER TABLE pages ADD COLUMN collection_row_id INTEGER " @@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None: conn.execute( "CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)" ) - cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()} + cols = columns(conn, "collections") if "form_config_json" not in cols: conn.execute( "ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'" @@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None: ) """ ) - cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + cols = columns(conn, "pages") if "search_excluded" not in cols: conn.execute( "ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0" @@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_worker_runs_worker " "ON worker_runs(worker_id, created_at)" ) - auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()} + auto_cols = columns(conn, "automations") if "trigger_mode" not in auto_cols: conn.execute( "ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'" ) - prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()} + prop_cols = columns(conn, "collection_properties") if "button_automation_id" not in prop_cols: conn.execute( "ALTER TABLE collection_properties ADD COLUMN button_automation_id " @@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page " "ON meeting_transcripts(page_id)" ) - cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()} + cols = columns(conn, "collection_pages") if "external_event_id" not in cols: conn.execute( "ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''" @@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_agent_approvals_status " "ON agent_approvals(status, created_at)" ) - user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()} + user_cols = columns(conn, "users") if "totp_secret_enc" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''") if "totp_backup_hashes" not in user_cols: @@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None: """ ) for table in ("pages", "collections"): - cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + cols = columns(conn, table) if "teamspace_id" not in cols: conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER") diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 38bca96..3a3bbdf 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.5.0" + "version": "7.6.0" }, "paths": { "/auth/register": { diff --git a/tests/test_audit_p0_fixes.py b/tests/test_audit_p0_fixes.py index 7417963..80b1dd0 100644 --- a/tests/test_audit_p0_fixes.py +++ b/tests/test_audit_p0_fixes.py @@ -163,6 +163,58 @@ def test_gitea_cache_evicts_expired(): assert "k" not in c._cache and c._cache["k2"][1] == "v2" +def test_migration_transaction_rolls_back(): + """A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni + ligne dans schema_version → la reprise rejoue proprement.""" + import sqlite3 as _sqlite3 + + import pytest as _pytest + + from app.migrations import _apply_one, _ensure_table + + conn = _sqlite3.connect(":memory:") + _ensure_table(conn) + + def boom(c): + c.execute("CREATE TABLE partial_x (id INTEGER)") + raise RuntimeError("boom") + + with _pytest.raises(RuntimeError, match="boom"): + _apply_one(conn, 9999, "boom", boom) + assert ( + conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone() + is None + ), "DDL partiel non annulé" + assert ( + conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0] + == 0 + ) + # chemin nominal : DDL + marque de version dans la même transaction + _apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)")) + assert ( + conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0] + == 1 + ) + conn.close() + + +def test_columns_helper_validates_table_name(): + """A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant.""" + import sqlite3 as _sqlite3 + + from app.migrations import columns + + conn = _sqlite3.connect(":memory:") + conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)") + assert columns(conn, "t1") == {"id", "nom"} + try: + columns(conn, "t1; DROP TABLE users") + raise AssertionError("identifiant non validé") + except ValueError: + pass + conn.close() + + def test_no_duplicate_routes(): """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" from app.main import app