diff --git a/tests/conftest.py b/tests/conftest.py index b8247f1..980b232 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -271,6 +271,26 @@ class FakeGitea: }, ] + # ── Snapshots (chantier B, 0.22.0) : le faux reflète + # l'interface du vrai client. + def hydrate_from_snapshots(self): + return 0 + + def latest_snapshot_at(self): + return None + + async def refresh_search_repos(self): + return await self.search_repos() + + async def refresh_starred(self): + return await self.list_starred() + + async def refresh_repo_issues(self, owner, repo, state="all"): + return await self.get_repo_issues(owner, repo, state=state) + + async def refresh_milestones(self, owner, repo): + return await self.get_milestones(owner, repo) + async def list_starred(self): return [r for r in self.repos if r["full_name"] in self.starred] diff --git a/tests/test_auth.py b/tests/test_auth.py index bdcb13b..81579bd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea): monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") with TestClient(main.app) as client: + # Sans en-tête navigateur : 401 (comportement historique). assert client.get("/").status_code == 401 + # Le Basic reste accepté pour les scripts (0.22.0). assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401 assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200 + # Un navigateur, lui, est envoyé vers l'écran de connexion. + r = client.get( + "/", headers={"Accept": "text/html"}, follow_redirects=False + ) + assert r.status_code == 303 + assert r.headers["location"].startswith("/login") # Liveness stays public for Docker HEALTHCHECK. assert client.get("/api/health").status_code == 200 diff --git a/tests/test_palier12.py b/tests/test_palier12.py index 66dc6f0..3a95816 100644 --- a/tests/test_palier12.py +++ b/tests/test_palier12.py @@ -64,7 +64,8 @@ def test_nav_js_contract(client, fake_gitea): assert "localStorage" in js.text assert "side-backdrop" in js.text assert "Escape" in js.text - assert "kanbante-nav-closed" in js.text # accordéons persistés + assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0) + assert "setOpenGroup" in js.text assert "data-group-btn" in js.text diff --git a/tests/test_palier18.py b/tests/test_palier18.py new file mode 100644 index 0000000..6984ff5 --- /dev/null +++ b/tests/test_palier18.py @@ -0,0 +1,139 @@ +"""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"