test: palier 18 — login/session, sidebar, hydratation des instantanés
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 '<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"
|
||||
Reference in New Issue
Block a user