Files
kanbante/tests/test_palier18.py

140 lines
4.7 KiB
Python

"""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 '<span class="side-label">Replier</span>' 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"