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).
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user