"""Palier 18 (0.22.0) — login screen, sidebar, chantier B. - Connexion par formulaire + cookie de session signé (le Basic reste accepté pour les scripts) ; les navigateurs sont redirigés vers /login, les API reçoivent 401. - Bouton replier dans l'en-tête de la barre latérale (icône seule, à droite de la recherche) ; accordéon exclusif. - Instantanés : persistance des réponses du client, hydratation au démarrage, étiquette de fraîcheur. """ import re import httpx from fastapi.testclient import TestClient import app.main as main from app import models from app.gitea_client import GiteaClient from app.snapshots import freshness_label def _auth_client(monkeypatch, fake_gitea): monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") return TestClient(main.app) def _csrf_from(html: str) -> str: m = re.search(r'name="csrf_token" value="([^"]+)"', html) assert m, "le formulaire de connexion doit porter le jeton CSRF" return m.group(1) def test_browser_is_redirected_to_login(monkeypatch, fake_gitea): with _auth_client(monkeypatch, fake_gitea) as client: r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False) assert r.status_code == 303 assert r.headers["location"].startswith("/login") # Les API, elles, reçoivent un 401 sec (pas de redirection). r = client.get("/api/repos") assert r.status_code == 401 def test_login_flow_and_logout(monkeypatch, fake_gitea): with _auth_client(monkeypatch, fake_gitea) as client: page = client.get("/login") assert page.status_code == 200 assert "login-card" in page.text token = _csrf_from(page.text) bad = client.post( "/login", data={"username": "bruno", "password": "non", "csrf_token": token}, follow_redirects=False, ) assert bad.status_code == 401 assert "Identifiants incorrects" in bad.text ok = client.post( "/login", data={ "username": "bruno", "password": "motdepasse", "csrf_token": token, "next": "/focus", }, follow_redirects=False, ) assert ok.status_code == 303 assert ok.headers["location"] == "/focus" # La session ouvre les pages sans autre en-tête. assert client.get("/").status_code == 200 client.get("/logout") r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False) assert r.status_code == 303 def test_login_next_is_local_only(monkeypatch, fake_gitea): with _auth_client(monkeypatch, fake_gitea) as client: token = _csrf_from(client.get("/login").text) r = client.post( "/login", data={ "username": "bruno", "password": "motdepasse", "csrf_token": token, "next": "https://evil.example/phish", }, follow_redirects=False, ) assert r.status_code == 303 assert r.headers["location"] == "/" def test_sidebar_head_and_exclusive_accordion(client): r = client.get("/") assert r.status_code == 200 head = r.text.split('class="side-nav"')[0] assert 'id="side-collapse"' in head # replier dans l'en-tête… assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche assert 'Replier' not in r.text # icône seule assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce) async def test_snapshot_write_through_and_hydration(): calls = {"n": 0} def handler(request): calls["n"] += 1 if request.url.path.endswith("/user"): return httpx.Response(200, json={"id": 7}) return httpx.Response( 200, json={"data": [{"full_name": "Projets/demo", "archived": False}]}, headers={"X-Total-Count": "1"}, ) writer = GiteaClient( base_url="http://gitea.test", cache_ttl=30, transport=httpx.MockTransport(handler), ) repos = await writer.search_repos() assert repos and repos[0]["full_name"] == "Projets/demo" stored = models.load_snapshots() assert "search_repos" in stored def boom(request): raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit") reader = GiteaClient( base_url="http://gitea.test", cache_ttl=30, transport=httpx.MockTransport(boom), ) assert reader.hydrate_from_snapshots() >= 1 served = await reader.search_repos() assert served[0]["full_name"] == "Projets/demo" assert freshness_label() == "à l'instant"