Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0698645dbd | ||
|
|
b2e38aece7 |
@@ -1,5 +1,49 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
|
||||
|
||||
### Tests
|
||||
|
||||
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
|
||||
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
|
||||
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
|
||||
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
|
||||
route/auth/parse, pas le moteur)
|
||||
· `POST /properties/compute-rollup` : 400 `collection_id required`,
|
||||
401 sans bearer
|
||||
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
|
||||
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
|
||||
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
|
||||
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
|
||||
· `POST /webhooks/verify-signature` : **valid=True** avec
|
||||
`sign_payload(secret, payload)` (même helper que le serveur), False avec
|
||||
une signature bidon
|
||||
- `test_smoke_uncovered.py` : 27 tests au total
|
||||
- Suite complète : **1064/1064**
|
||||
|
||||
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
|
||||
|
||||
### Tests
|
||||
|
||||
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
|
||||
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
|
||||
relecture du roundtrip
|
||||
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
|
||||
suppression vérifiée
|
||||
· `card` POST : 404 sans board, `{"status": "ok"}` avec
|
||||
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
|
||||
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
|
||||
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
|
||||
· checklist mutations : PATCH item (checked/content relus EN BASE),
|
||||
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
|
||||
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
|
||||
stub de transport httpx. Reste global : `dashboard.py` 17/63,
|
||||
`api_v2.py` 50/115
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
|
||||
|
||||
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
|
||||
|
||||
### Tests
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.12.0 (audit — A32 phase 2 : library 10/10, 2 routes mortes supprimées) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.14.0 (audit — A32 phase 2c : api_v2 +5 routes à 0 ref) | **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
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.12.0",
|
||||
version="7.14.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.12.0"
|
||||
"version": "7.14.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -184,3 +184,221 @@ def test_library_lists_still_available_after_removal(client):
|
||||
"""Les listes restantes (celles qu'utilise le front) répondent toujours."""
|
||||
r = client.get("/api/library/recents")
|
||||
assert r.status_code == 200 and isinstance(r.json()["items"], list)
|
||||
|
||||
|
||||
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
|
||||
|
||||
def _seed_board(client) -> None:
|
||||
"""Board via l'endpoint lui-même (pas de SQL brut)."""
|
||||
r = client.post(
|
||||
f"/api/board-config/{OWNER}/{REPO}",
|
||||
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_api_board_config_default_then_roundtrip(client):
|
||||
# pas de board → défauts
|
||||
r = client.get(f"/api/board-config/{OWNER}/other-repo")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
|
||||
# création puis relecture
|
||||
_seed_board(client)
|
||||
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
|
||||
assert back.json()["columns"] == ["Todo", "Doing"]
|
||||
|
||||
|
||||
def test_api_col_mapping_requires_board_and_works(client):
|
||||
# board inexistant → 404
|
||||
r = client.post(
|
||||
"/api/col-mapping",
|
||||
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
# avec board → ok (upsert) puis suppression
|
||||
_seed_board(client)
|
||||
r2 = client.post(
|
||||
"/api/col-mapping",
|
||||
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
|
||||
r3 = client.delete(
|
||||
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
|
||||
)
|
||||
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
|
||||
|
||||
|
||||
def test_api_card_metadata(client):
|
||||
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
|
||||
assert r.status_code == 404 # board requis
|
||||
_seed_board(client)
|
||||
r2 = client.post(
|
||||
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
|
||||
)
|
||||
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_api_collaborators_stubbed(client, monkeypatch):
|
||||
from app.services import gitea_client
|
||||
|
||||
async def _none(*_a, **_k):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
|
||||
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"collaborators": []}
|
||||
|
||||
|
||||
def test_api_checklist_item_toggle_and_deletes(client):
|
||||
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
|
||||
from app.db import get_conn
|
||||
|
||||
_seed_board(client)
|
||||
with get_conn() as conn:
|
||||
board_id = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(OWNER, REPO),
|
||||
).fetchone()["id"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
|
||||
(board_id,),
|
||||
)
|
||||
cl_id = cur.lastrowid
|
||||
cur = conn.execute(
|
||||
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
|
||||
(cl_id,),
|
||||
)
|
||||
item_id = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
r = client.patch(
|
||||
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
|
||||
)
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
|
||||
).fetchone()
|
||||
assert row["checked"] == 1 and row["content"] == "fait !"
|
||||
|
||||
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
|
||||
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
|
||||
with get_conn() as conn:
|
||||
left = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
|
||||
).fetchone()["n"]
|
||||
assert left == 0
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
|
||||
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _v2_headers(client, login: str) -> dict:
|
||||
"""Compte local + token v1 en session (recette de test_public_api_v2)."""
|
||||
r = client.post(
|
||||
"/auth/register",
|
||||
json={"email": f"{login}@test.dev", "password": "secret123", "name": login},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
tok = client.post("/api/v1/token").json()["token"]
|
||||
return {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
|
||||
def test_api_frontend_error_capture_and_read(client):
|
||||
# purge d'un éventuel résidu d'un autre test
|
||||
client.get("/api/frontend-errors")
|
||||
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
|
||||
invalid = client.post(
|
||||
"/api/frontend-error", content="pas du json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
|
||||
# doublon consécutif dédupliqué (count=2), lecture puis purge
|
||||
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||
out = client.get("/api/frontend-errors").json()
|
||||
assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True
|
||||
assert client.get("/api/frontend-errors").json()["count"] == 0
|
||||
|
||||
|
||||
# ── api_v2.py (+5 routes à 0 ref) ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_v2_evaluate_formula(client):
|
||||
headers = _v2_headers(client, "smoke-formula")
|
||||
r = client.post(
|
||||
"/api/v2/properties/evaluate-formula",
|
||||
json={"expression": "1 + 2"}, headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
# le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) :
|
||||
# on valide le câble route (auth, parse, shape), pas le moteur lui-même.
|
||||
assert d["expression"] == "1 + 2" and "result" in d
|
||||
missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers)
|
||||
assert missing.status_code == 400 and "expression" in missing.json()["detail"]
|
||||
|
||||
|
||||
def test_v2_compute_rollup_validates_inputs(client):
|
||||
headers = _v2_headers(client, "smoke-rollup")
|
||||
r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers)
|
||||
assert r.status_code == 400
|
||||
assert "collection_id required" in r.json()["detail"]
|
||||
sans_auth = client.post("/api/v2/properties/compute-rollup", json={})
|
||||
assert sans_auth.status_code == 401
|
||||
|
||||
|
||||
def test_v2_admin_audit_logs_gated_then_readable(client):
|
||||
headers = _v2_headers(client, "smoke-audit")
|
||||
# le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur
|
||||
# du worker est admin) → on calcule l'attendu depuis /users/me plutôt que
|
||||
# de durcir un état qu'on ne contrôle pas.
|
||||
me = client.get("/api/v2/users/me", headers=headers).json()
|
||||
r = client.get("/api/v2/admin/audit-logs", headers=headers)
|
||||
if me.get("is_admin"):
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
assert "Admin scope" in r.json()["detail"]
|
||||
# token avec scope admin → 200 + liste paginée
|
||||
admin_tok = client.post(
|
||||
"/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers
|
||||
).json()["token"]
|
||||
r2 = client.get(
|
||||
"/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert isinstance(r2.json()["logs"], list)
|
||||
|
||||
|
||||
def test_v2_webhooks_events_catalogue(client):
|
||||
headers = _v2_headers(client, "smoke-events")
|
||||
r = client.get("/api/v2/webhooks/events", headers=headers)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert isinstance(d["events"], list) and len(d["events"]) > 0
|
||||
assert "*" in d["wildcards"] and "page.*" in d["wildcards"]
|
||||
|
||||
|
||||
def test_v2_webhook_verify_signature(client):
|
||||
from app.services.webhook_outbound import sign_payload
|
||||
|
||||
headers = _v2_headers(client, "smoke-sig")
|
||||
secret, payload = "topsecret", '{"event": "page.updated"}'
|
||||
good = sign_payload(secret, payload.encode())
|
||||
ok = client.post(
|
||||
"/api/v2/webhooks/verify-signature",
|
||||
json={"secret": secret, "payload": payload, "signature": good},
|
||||
headers=headers,
|
||||
)
|
||||
assert ok.status_code == 200 and ok.json()["valid"] is True
|
||||
bad = client.post(
|
||||
"/api/v2/webhooks/verify-signature",
|
||||
json={"secret": secret, "payload": payload, "signature": "deadbeef"},
|
||||
headers=headers,
|
||||
)
|
||||
assert bad.json()["valid"] is False
|
||||
|
||||
Reference in New Issue
Block a user