"""FlowDeck — bouton « Home » de la sidebar : le paramètre ``?ws=`` est respecté. Le sidebar construit ``/local-workspace?ws=`` (premier workspace possédé, trié par nom). La route déclarait seulement ``folder`` : ``ws`` était ignoré et la page affichait le workspace **actif** (cookie), donc Home pouvait montrer un workspace différent de celui annoncé dans l'URL. Ces tests verrouillent le contrat : * ``?ws=`` affiche bien le workspace demandé (et aligne le cookie) ; * un ``?ws=`` appartenant à quelqu'un d'autre est ignoré — aucune fuite ; * sans ``?ws=``, le comportement précédent (workspace actif) est inchangé. """ from __future__ import annotations import pytest from conftest import login_test_client @pytest.fixture def client(): import os import tempfile db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() 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" from app.config import settings from app.db import init_db from app.main import app settings.database_url = f"sqlite:///{db_path}" settings.llm_provider = "offline" init_db() from fastapi.testclient import TestClient client = login_test_client(TestClient(app)) try: yield client finally: try: os.unlink(db_path) except FileNotFoundError: pass def _make_workspace(client, name: str) -> dict: r = client.post("/api/workspaces", json={"name": name}) assert r.status_code == 200, r.text return r.json() def _workspace_id_in(client, workspace_id: int) -> int | None: """Lit le workspace_id embarqué dans le HTML rendu (lw-config).""" r = client.get(f"/local-workspace?ws={workspace_id}") assert r.status_code == 200, r.text body = r.text marker = '"workspace_id":' idx = body.find(marker) assert idx != -1, "lw-config absent de la page" rest = body[idx + len(marker):].lstrip() digits = "" for ch in rest: if ch.isdigit(): digits += ch else: break return int(digits) if digits else None def test_home_ws_param_is_honoured(client): """Home doit afficher le workspace qu'il annonce, pas celui du cookie.""" client.post("/auth/register", json={ "email": "home1@test.dev", "password": "secret123", "name": "Home1"}) zeta = _make_workspace(client, "Zeta Project") # id plus élevé alpha = _make_workspace(client, "Alpha Project") # premier par nom # Le cookie « workspace actif » pointe sur Zeta… client.post(f"/api/workspaces/{zeta['id']}/select") # …mais Home annonce le premier par nom (Alpha). assert _workspace_id_in(client, alpha["id"]) == alpha["id"] assert _workspace_id_in(client, zeta["id"]) == zeta["id"] def test_home_ws_param_title_matches_content(client): """Le titre affiché doit être celui du workspace demandé, pas celui du cookie. Régression : la sidebar est calculée avant le changement de workspace, donc `active_ws_name` désignait l'ancien workspace — le contenu aurait été celui de Zeta sous le titre « Alpha Project ». """ client.post("/auth/register", json={ "email": "home5@test.dev", "password": "secret123", "name": "Home5"}) zeta = _make_workspace(client, "Zeta Project") alpha = _make_workspace(client, "Alpha Project") client.post(f"/api/workspaces/{zeta['id']}/select") r = client.get(f"/local-workspace?ws={alpha['id']}") assert r.status_code == 200 # La sidebar liste légitimement tous les workspaces : on cible le , # qui reflète le workspace dont le contenu est rendu. import re title = re.search(r"<title>(.*?)", r.text, re.S).group(1) assert "Alpha Project" in title assert "Zeta Project" not in title def test_home_ws_param_aligns_cookie(client): """Après un Home vers ?ws=, le cookie doit suivre le workspace affiché.""" client.post("/auth/register", json={ "email": "home2@test.dev", "password": "secret123", "name": "Home2"}) a = _make_workspace(client, "AAA") b = _make_workspace(client, "BBB") client.post(f"/api/workspaces/{a['id']}/select") r = client.get(f"/local-workspace?ws={b['id']}") assert r.status_code == 200 assert client.cookies.get("flowdeck_workspace") == str(b["id"]) def test_foreign_ws_param_is_ignored(client): """Un ?ws= appartenant à un autre utilisateur ne doit rien divulguer.""" client.post("/auth/register", json={ "email": "home3@test.dev", "password": "secret123", "name": "Home3"}) mine = _make_workspace(client, "Mine") # Workspace appartenant à un AUTRE utilisateur (FK users respectée) from app.db import get_conn from app.password_utils import hash_password with get_conn() as conn: conn.execute( "INSERT INTO users (login, email, full_name, password_hash, is_active) " "VALUES ('ghost', 'ghost@test.dev', 'Ghost', ?, 1)", (hash_password("secret123"),)) conn.commit() ghost_id = conn.execute("SELECT id FROM users WHERE login='ghost'").fetchone()["id"] conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("Secret Corp", ghost_id)) conn.commit() foreign = conn.execute( "SELECT id FROM workspaces WHERE name='Secret Corp'").fetchone()["id"] r = client.get(f"/local-workspace?ws={foreign}") assert r.status_code == 200 # Le workspace demandé n'est pas rendu : on retombe sur celui du visiteur. assert _workspace_id_in(client, foreign) == mine["id"] assert client.cookies.get("flowdeck_workspace") != str(foreign) assert "Secret Corp" not in r.text def test_without_ws_param_active_workspace_is_used(client): """Sans ?ws=, le comportement historique est inchangé.""" client.post("/auth/register", json={ "email": "home4@test.dev", "password": "secret123", "name": "Home4"}) _make_workspace(client, "Active") b = _make_workspace(client, "Other") client.post(f"/api/workspaces/{b['id']}/select") assert _workspace_id_in(client, b["id"]) == b["id"]