fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user