fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
  404 si la page n'existe pas, événements centralisés. Les 3 paires
  publish/unpublish déléguent (sharing = front, board, v2) :
  · board : mise à jour aveugle → 404 + contrôle de session ajouté
  · board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
    le share dialog reste l'unique propriétaire de ces drapeaux
  · v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
  · `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
    décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
  faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
  exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
  → `settings.data_dir` (property : lecture à chaque accès, les tests
  monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
  écriture. Reste : client httpx partagé (52 créations, cache par event loop)

tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired

suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
This commit is contained in:
2026-10-01 10:01:39 -04:00
parent 937ecfc2e0
commit 7be96f0618
19 changed files with 258 additions and 126 deletions
+69
View File
@@ -94,6 +94,75 @@ def test_asset_version_single_source():
assert literals == [], literals
def test_publish_service_shared_and_safe(client):
"""A29 : les 3 routers déléguent — 404 sur page absente, slug unique,
dépublication qui ne touche pas aux partages manuels."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, share_mode) "
"VALUES (1, 'Publie moi', 'contenu', 'markdown', 'anyone')",
)
pid = cur.lastrowid
conn.commit()
try:
r = client.post(f"/api/pages/{pid}/publish")
assert r.status_code == 200, r.text
slug = r.json()["publish_slug"]
assert slug # slugify du titre
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 1 and row["publish_slug"] == slug
assert row["share_mode"] == "anyone" # intouché (share dialog propriétaire)
r2 = client.delete(f"/api/pages/{pid}/publish")
assert r2.status_code == 200
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 0 and row["publish_slug"] == ""
assert row["share_mode"] == "anyone" # dépublier ne révoque pas le partage
# 404 sur page inexistante — les deux chemins passent par le service
assert client.post("/api/pages/999999/publish").status_code == 404
assert client.delete("/api/pages/999999/publish").status_code == 404
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_users_me_no_secret_columns(client):
"""A29-byproduct : GET /api/users/me (v1) ne doit plus renvoyer password_hash."""
r = client.get("/api/users/me")
assert r.status_code == 200, r.text
body = r.json()
assert "password_hash" not in body, list(body)
assert "locked_until" not in body and "login_attempts" not in body
assert body.get("login") # la réponse reste exploitable
def test_gitea_cache_evicts_expired():
"""A42 : les entrées expirées sortent du cache à chaque écriture."""
from datetime import datetime, timedelta
from app.services.gitea_client import GiteaClient
c = GiteaClient.__new__(GiteaClient) # sans appel réseau
c._cache = {}
c._ttl = timedelta(seconds=1)
c._set_cache("k", "v")
assert c._cached("k") == "v"
# expire l'entrée puis force une autre écriture → la précédente est évacuée
c._cache["k"] = (datetime.now() - timedelta(seconds=1), "v")
c._set_cache("k2", "v2")
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app