Files
flowdeck/tests/test_anomalies_v746.py
T
bruno 1d1cdbd618
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
2026-10-05 22:47:36 -04:00

377 lines
14 KiB
Python

"""FlowDeck — correctifs d'anomalies v7.46.0 (audit de fonctionnement).
Chaque test verrouille une anomalie réellement constatée sur l'instance :
* **P0-3** ``/auth/user`` renvoyait le ``password_hash`` (et le cookie de
session, signé mais non chiffré, l'embarquait) ;
* **P0-4** ``gitea_oauth_client_id`` vaut le placeholder ``test-id`` →
``/auth/login`` redirigeait vers Gitea avec un client_id invalide ;
* **P0-1** écritures anonymes sur ``/api/workspaces*`` et
``/api/local-workspace/items`` (``uid = ... else 1``) ;
* **P0-2** ``/workspace/*`` sans session : export CSV, historique, commentaires ;
* **P1-5** bouton Home : ``local_workspaces[0]`` (ordre alphabétique) au lieu
de l'espace actif ;
* **P1-6** ``/workspace/favorites`` : 500 permanent (colonne ``collection_id``
supprimée par la migration v2.2.0) ;
* **P1-7** ``/api/v2/agents/conversations`` masqué par ``/agents/{agent_id}`` ;
* **P1-8** l'éditeur de page appelait ``/api/synced-blocks`` (404) au lieu de
``/board/api/synced-blocks`` ;
* **P1-9** ``/board/api/synced-blocks`` : 500 anonyme + absence de contrôle de
propriété sur PUT/DELETE.
"""
from __future__ import annotations
import re
import pytest
from conftest import anon_csrf, login_test_client
@pytest.fixture
def client():
"""Same isolated temp DB contract as tests/conftest.py::client."""
import os
import tempfile
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
data_dir = tempfile.mkdtemp(prefix="fd_data_")
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
os.environ["LLM_PROVIDER"] = "offline"
os.environ["FLOWDECK_DATA_DIR"] = data_dir
import app.config
s = app.config.settings
s.database_url = f"sqlite:///{db_path}"
s.app_secret_key = "test-secret-for-tests"
s.rate_limit_enabled = False
s.backup_enabled = False
s.backup_dir = backup_dir
s.project_sync_enabled = False
from app.db import init_db
from app.main import app
init_db()
from fastapi.testclient import TestClient
c = login_test_client(TestClient(app))
try:
yield c
finally:
try:
os.unlink(db_path)
except FileNotFoundError:
pass
def _mk_ws(client, name: str, owner: int = 1) -> int:
r = client.post("/api/workspaces", json={"name": name})
assert r.status_code == 200, r.text
return r.json()["id"]
# ══════════════════════ P0-3 — password_hash ══════════════════════
def test_auth_user_never_exposes_password_hash(client):
r = client.get("/auth/user")
assert r.status_code == 200
assert "password_hash" not in r.text
def test_session_cookie_does_not_embed_password_hash(client):
from app.auth.session import SessionManager, public_user
sanitized = public_user({"id": 1, "login": "a", "password_hash": "deadbeef"})
assert "password_hash" not in sanitized
raw = SessionManager.create_session(
{"id": 1, "login": "a", "password_hash": "deadbeef", "is_admin": 1}
)
assert "deadbeef" not in raw
# Le payload décodé ne contient plus le champ sensible (décodé par la même
# instance de serializer que le serveur, independamment de `settings`).
decoded = SessionManager.decode_session(raw)
assert decoded is not None
assert "password_hash" not in decoded
assert decoded["login"] == "a"
# ══════════════════════ P0-4 — OAuth placeholder ══════════════════════
def test_placeholder_gitea_credentials_are_not_enabled(client):
import app.auth.providers as providers
gitea = providers.GiteaProvider(
base_url="https://git.example.net",
client_id="test-id",
client_secret="test-secret",
redirect_uri="",
)
assert gitea.is_enabled() is False
def test_login_does_not_redirect_to_placeholder_client(client):
r = client.get("/auth/login", follow_redirects=False)
assert r.status_code == 200
assert "client_id=test-id" not in r.text
assert "not configured" in r.text.lower()
def test_login_redirects_when_credentials_are_real(client):
from app.config import settings
old_id, old_secret = settings.gitea_oauth_client_id, settings.gitea_oauth_client_secret
settings.gitea_oauth_client_id = "real-id"
settings.gitea_oauth_client_secret = "real-secret"
try:
r = client.get("/auth/login", follow_redirects=False)
assert r.status_code == 302
assert "client_id=real-id" in r.headers["location"]
finally:
settings.gitea_oauth_client_id = old_id
settings.gitea_oauth_client_secret = old_secret
# ══════════════════════ P0-1 — écritures anonymes ══════════════════════
@pytest.mark.parametrize(
"method,path,body",
[
("post", "/api/workspaces", {"name": "ANON"}),
("put", "/api/workspaces/1", {"name": "ANON"}),
("delete", "/api/workspaces/1", None),
("post", "/api/workspaces/1/select", {}),
("post", "/api/local-workspace/items", {"name": "ANON"}),
("put", "/api/local-workspace/items/1", {"name": "ANON"}),
("delete", "/api/local-workspace/items/1", None),
("post", "/api/local-workspace/items/1/restore", {}),
("put", "/api/local-workspace/items/1/move", {"parent_id": None}),
],
)
def test_anonymous_write_is_rejected(client, method, path, body):
c = anon_csrf(client)
fn = getattr(c, method)
r = fn(path, json=body) if body is not None else fn(path)
assert r.status_code == 401, f"{method.upper()} {path} -> {r.status_code} {r.text[:200]}"
def test_anonymous_workspace_write_creates_nothing(client):
ws_id = _mk_ws(client, "Real WS")
c = anon_csrf(client)
assert c.post("/api/workspaces", json={"name": "Ghost"}).status_code == 401
assert c.delete(f"/api/workspaces/{ws_id}").status_code == 401
from app.db import get_conn
with get_conn() as conn:
names = [r[0] for r in conn.execute("SELECT name FROM workspaces")]
assert "Ghost" not in names
assert "Real WS" in names
def test_workspace_rename_delete_require_ownership(client):
ws_id = _mk_ws(client, "WS of admin")
other = login_test_client(client, user_id=2, login="intruder", is_admin=0)
assert other.put(f"/api/workspaces/{ws_id}", json={"name": "hijacked"}).status_code == 403
assert other.delete(f"/api/workspaces/{ws_id}").status_code == 403
assert other.post(f"/api/workspaces/{ws_id}/select").status_code == 403
from app.db import get_conn
with get_conn() as conn:
assert conn.execute(
"SELECT name FROM workspaces WHERE id=?", (ws_id,)
).fetchone()[0] == "WS of admin"
# ══════════════════════ P0-2 — /workspace authentifié ══════════════════════
@pytest.mark.parametrize(
"path",
[
"/workspace/favorites",
"/workspace/pages/1/comments",
"/workspace/pages/1/history",
"/workspace/templates/database",
"/workspace/collections/1/export/csv",
"/workspace/collections/1/sprints",
"/workspace/collections/1/dashboards",
],
)
def test_workspace_router_requires_session(client, path):
c = anon_csrf(client)
r = c.get(path)
assert r.status_code == 401, f"{path} -> {r.status_code} {r.text[:160]}"
def test_public_sharing_route_stays_public(client):
"""La seule route publique du router `/workspace` reste accessible."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO collections (name) VALUES ('Public')")
conn.commit()
cid = conn.execute("SELECT MAX(id) FROM collections").fetchone()[0]
conn.execute(
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
(cid, "Page publique"),
)
conn.commit()
r = anon_csrf(client).get(f"/workspace/public/{cid}")
assert r.status_code == 200, r.text[:200]
assert "Page publique" in r.text
# Une collection absente ne doit surtout pas exiger une session (401).
r2 = anon_csrf(client).get("/workspace/public/424242")
assert r2.status_code != 401
# ══════════════════════ P1-6 — favorites ══════════════════════
def test_favorites_roundtrip_uses_current_schema(client):
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) "
"VALUES ('WS','Fav page','','blocks')"
)
conn.commit()
page_id = conn.execute("SELECT MAX(id) FROM pages").fetchone()[0]
r = client.get("/workspace/favorites")
assert r.status_code == 200, r.text
assert r.json()["favorites"] == []
r = client.post("/workspace/favorites", json={"page_id": page_id})
assert r.status_code == 200, r.text
r = client.get("/workspace/favorites")
assert r.status_code == 200, r.text
favs = r.json()["favorites"]
assert len(favs) == 1
assert favs[0]["page_id"] == page_id
assert favs[0]["page_title"] == "Fav page"
assert client.delete(f"/workspace/favorites/{favs[0]['id']}").status_code == 200
assert client.get("/workspace/favorites").json()["favorites"] == []
def test_favorites_add_requires_page_id(client):
assert client.post("/workspace/favorites", json={}).status_code == 400
# ══════════════════════ P1-7 — ordre de routes agents ══════════════════════
def test_agents_conversations_route_is_not_shadowed(client):
r = client.get("/api/v2/agents/conversations")
# 401 « API token required » = la route a bien été atteinte (avant : 422
# int_parsing sur /agents/{agent_id}).
assert r.status_code == 401, r.text
assert "int_parsing" not in r.text
# ══════════════════════ P1-8 / P1-9 — synced blocks ══════════════════════
def test_page_editor_uses_board_prefix_for_synced_blocks():
from pathlib import Path
src = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
assert "'/api/synced-blocks'" not in src
assert src.count("'/board/api/synced-blocks'") == 1
assert "'/board/api/synced-blocks/'" in src
assert "Settings → Synced Blocks" not in src
def test_synced_blocks_anonymous_is_401_not_500(client):
c = anon_csrf(client)
assert c.get("/board/api/synced-blocks").status_code == 401
assert c.get("/board/api/synced-blocks/1").status_code == 401
assert c.post("/board/api/synced-blocks", json={"title": "x"}).status_code == 401
def test_synced_block_cannot_be_edited_by_another_user(client):
r = client.post("/board/api/synced-blocks", json={"title": "Bloc", "content": []})
assert r.status_code == 200, r.text
sid = r.json()["synced_block_id"]
intruder = login_test_client(client, user_id=2, login="intruder", is_admin=0)
assert intruder.put(f"/board/api/synced-blocks/{sid}", json={"title": "pwn"}).status_code == 403
assert intruder.delete(f"/board/api/synced-blocks/{sid}").status_code == 403
from app.services.synced_blocks import get_synced_block
assert get_synced_block(sid)["title"] == "Bloc"
owner = login_test_client(client, user_id=1, login="tester", is_admin=1)
assert owner.put(f"/board/api/synced-blocks/{sid}", json={"title": "ok"}).status_code == 200
assert get_synced_block(sid)["title"] == "ok"
def test_synced_blocks_list_with_session(client):
r = client.get("/board/api/synced-blocks")
assert r.status_code == 200, r.text
assert "synced_blocks" in r.json()
# ══════════════════════ P1-5 — bouton Home ══════════════════════
def test_undefined_template_variable_is_logged_not_silent(caplog):
"""P1-10 : une variable absente du contexte rend ``""`` (contrattenu par
40+ templates optionnelles) mais ne doit plus être totally silencieuse."""
import logging
from app.templating import ENV
with caplog.at_level(logging.WARNING, logger="app.templating"):
out = ENV.from_string("{{ totally_unknown_variable }}").render()
assert out == ""
assert any(
"totally_unknown_variable" in r.getMessage() for r in caplog.records
), [r.getMessage() for r in caplog.records]
def test_home_button_targets_the_active_workspace(client):
"""``local_workspaces[0]`` = premier workspace TRIÉ PAR NOM ; le Home doit
cibler l'espace actif (``local_ws_id``)."""
from pathlib import Path
tpl = Path("app/templates/base.html").read_text(encoding="utf-8")
m = re.search(r'<a href="([^"]*)" class="nav-icon-btn home-btn[^"]*"', tpl)
assert m, "lien Home introuvable dans base.html"
href = m.group(1)
assert "local_ws_id" in href, href
assert href.index("local_ws_id") < href.index("local_workspaces")
def test_home_button_renders_active_workspace_id(client):
from app.db import get_conn
a = _mk_ws(client, "AAA first alphabetically")
b = _mk_ws(client, "ZZZ active")
client.post(f"/api/workspaces/{b}/select")
r = client.post("/api/local-workspace/items", json={"name": "HomeProbe", "workspace_id": b})
assert r.status_code == 200, r.text
pid = r.json()["id"]
r = client.get(f"/pages/{pid}")
assert r.status_code == 200, r.status_code
m = re.search(r'<a href="([^"]*)" class="nav-icon-btn home-btn[^"]*"', r.text)
assert m, "lien Home absent du HTML rendu"
assert m.group(1) == f"/local-workspace?ws={b}"
assert f"/local-workspace?ws={a}" != m.group(1)
with get_conn() as conn:
names = {r[0] for r in conn.execute("SELECT name FROM workspaces")}
assert {"AAA first alphabetically", "ZZZ active"} <= names