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):
|
async def list_starred(self):
|
||||||
return [r for r in self.repos if r["full_name"] in self.starred]
|
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_USER", "bruno")
|
||||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
with TestClient(main.app) as client:
|
with TestClient(main.app) as client:
|
||||||
|
# Sans en-tête navigateur : 401 (comportement historique).
|
||||||
assert client.get("/").status_code == 401
|
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", "mauvais")).status_code == 401
|
||||||
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
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.
|
# Liveness stays public for Docker HEALTHCHECK.
|
||||||
assert client.get("/api/health").status_code == 200
|
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 "localStorage" in js.text
|
||||||
assert "side-backdrop" in js.text
|
assert "side-backdrop" in js.text
|
||||||
assert "Escape" 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
|
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