140 lines
4.7 KiB
Python
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"
|