Module 02 — Navigation & Authentification

Étape 2 de la mission « QA Engineer d’une flotte GenAI multi-tenant ». « Sécurise ton accès et explore l’admin. »

Ce notebook est la couche pédagogique du module 02. Il raconte le module, lit le banc de tests réel (02-navigation-auth.spec.ts), en orchestre l’inventaire et propose des exercices exécutables. Le fichier .spec.ts reste le moteur de test (backend) : on ne le remplace pas, on l’enrobe.

Portabilité. Toutes les cellules de ce notebook s’exécutent sans navigateur ni instance Open WebUI en ligne : elles lisent le code des tests et en dressent l’inventaire. Lancer réellement les tests E2E (contre une vraie plateforme) est documenté au §4 et fait l’objet du capstone — ici on apprend à lire, cartographier et raisonner sur la suite.

1. Objectifs & place dans la mission

La mission compte cinq étapes. Tu es à la deuxième : la prise en main de l’outillage est faite, il faut maintenant sécuriser l’accès et prouver que la navigation et les permissions fonctionnent — porte d’entrée de tout ce qui suit (on ne certifie pas un chat si on ne sait pas certifier un login).

À la fin de ce module, tu sais :

  • comprendre le pattern storageState : s’authentifier une fois, réutiliser la session ;
  • tester le RBAC (accès admin réservé) et la navigation entre sections ;
  • lire les routes SvelteKit d’Open WebUI (/admin, /workspace/*, /channels) ;
  • gérer les libellés multilingues (FR/EN) qui dérivent entre versions ;
  • distinguer un échec d’authentification d’un rate-limiting (HTTP 429).
Étape Module Ce que tu certifies
1 01 — Découverte Mon outillage fonctionne, je sais lire un test.
2 02 — Navigation & Auth (ici) L’accès et l’admin.
3 03 — Chat & Streaming Le cœur métier (chat IA).
4 04 — RAG, outils & canaux Les fonctions avancées.
5 05 — Multi-tenant & CI/CD L’isolation + l’industrialisation.
# --- Setup : localiser la serie et le module, SANS exposer de chemin absolu ---
from pathlib import Path
import re, shutil, subprocess

def _redact(texte: str) -> str:
    # Anti-fuite : ne jamais afficher de chemin absolu (machine de l'auteur, home...).
    out = texte
    for absolu in {str(Path.cwd().resolve()), str(Path.home())}:
        out = out.replace(absolu, ".")
        out = out.replace(absolu.replace("/", "\\"), ".")
    return out

def trouver_racine_serie(depart=None) -> Path:
    # La racine de la serie = le dossier qui contient playwright.config.ts.
    p = Path(depart or Path.cwd()).resolve()
    for cand in [p, *p.parents]:
        if (cand / "playwright.config.ts").exists():
            return cand
    for sub in Path.cwd().resolve().rglob("playwright.config.ts"):
        return sub.parent
    raise FileNotFoundError("playwright.config.ts introuvable depuis le dossier courant")

SERIE = trouver_racine_serie()
MODULE = SERIE / "02-navigation-authentification"
SPEC = MODULE / "02-navigation-auth.spec.ts"

print("Serie       :", SERIE.name)
print("Module      :", MODULE.name)
print("Banc de test:", SPEC.name, "(present)" if SPEC.exists() else "(ABSENT)")
Serie       : Playwright-OWUI
Module      : 02-navigation-authentification
Banc de test: 02-navigation-auth.spec.ts (present)

2. Le pattern storageState — s’authentifier une seule fois

L’écueil n°1 du test d’authentification, c’est le rate-limiting : l’endpoint /api/v1/auths/signin d’Open WebUI tolère peu de connexions rapprochées (~2 min entre appels). Si chaque test refaisait un login, on atteindrait le 429 avant la fin du module.

La solution Playwright : un setup dédié se connecte une fois, puis sauvegarde l’état du navigateur (cookies, localStorage, tokens) dans un fichier JSON. Tous les tests chargent ensuite cet état — ils démarrent déjà connectés.

1. auth.setup.ts  →  Login via formulaire  →  Sauvegarde .auth/owui.json
2. *.spec.ts      →  Charge .auth/owui.json →  Déjà connecte !

C’est plus rapide (un seul login) et plus robuste (on ne frappe pas le rate-limiter). Le storageState est déclaré une fois dans playwright.config.ts (projet authenticated) et propagé à tous les specs qui en dépendent. Retiens le principe : l’authentification est un fixture, pas une étape de test.

# --- Lire le banc de tests et en extraire la structure (sans le lancer) ---
texte = SPEC.read_text(encoding="utf-8")

# Titres des tests : test('....', ...) — le spec utilise des guillemets simples
titres = re.findall(r"test\('([^']+)'", texte)
# Le bloc describe (le theme du module)
describe = re.search(r"describe\('([^']+)'", texte)

print("Theme du module :", describe.group(1) if describe else "(non trouve)")
print()
print(f"{len(titres)} test(s) defini(s) dans {SPEC.name} :")
for i, t in enumerate(titres, 1):
    print(f"  {i}. {t}")

# Compter les exercices embarques (commentaires // EXERCICE)
nb_ex = len(re.findall(r"//\s*EXERCICE", texte))
print()
print(f"{nb_ex} exercice(s) embarque(s) dans les commentaires du spec.")
Theme du module : 02 — Navigation & Authentification

8 test(s) defini(s) dans 02-navigation-auth.spec.ts :
  1. la session authentifiee est valide et persistante
  2. acceder au panneau admin — liste des utilisateurs
  3. acceder aux reglages admin
  4. workspace — lister les bases de connaissances
  5. workspace — lister les modeles personnalises
  6. workspace — lister les prompts
  7. workspace — lister les fonctions
  8. acceder a la section Channels

3 exercice(s) embarque(s) dans les commentaires du spec.

3. RBAC et routes SvelteKit — qui peut aller où

Open WebUI est une application SvelteKit : chaque section est une route. Le module 02 en parcourt quatre familles, chacune avec son niveau d’accès (le RBAC — Rôle-Based Access Control) :

Route Section Accès
/ , /c/{id} Chat (nouvelle / existante conversation) Utilisateur connecté
/admin Panneau admin — liste des utilisateurs Admin uniquement
/admin/settings Réglages — v0.11 : redirige vers /, les réglages vivent dans une fenêtre à onglets (page distincte jusqu’à la v0.10) Admin uniquement
/workspace/* Bases de connaissances, modèles, prompts Utilisateur connecté
/admin/functions Fonctions (plugins : filtres, actions, outils) — gérées côté admin Admin uniquement
/channels Canaux de discussion collaboratifs Utilisateur connecté

Regardons le test d’accès admin (code réel du spec, annoté) — il illustre la navigation (goto), l’assertion localisée (le libellé dépend de la langue de l’interface) et l’assertion de donnée (la table est rendue) :

test('acceder au panneau admin — liste des utilisateurs', async ({ page }) => {
  await page.goto('/admin');                       // 1. naviguer vers la route admin

  // 2. assertion localisée : « Utilisateurs » (FR) ou « Users » (EN) —
  //    .or() accepte les deux, robuste au drift de traduction
  await expect(
    page.getByText('Utilisateurs').or(page.getByText('Users')).first()
  ).toBeVisible({ timeout: 15_000 });

  // 3. assertion de donnée : la table des utilisateurs est bien rendue
  await expect(page.locator('table')).toBeVisible({ timeout: 15_000 });
});

Deux leçons : (1) goto('/admin') suffit — si le storageState est valide et le rôle admin, on arrive ; sinon, SvelteKit redirige (le test échoue proprement). (2) .or() accepte les deux langues : un sélecteur multilingue résiste au drift de libellé entre versions — c’est exactement le réflexe anti-casse qu’on cherche. Et l’assertion ne s’arrête pas au titre : vérifier la table confirme que la page a réellement chargé ses données, pas seulement son en-tête.

# --- Inventaire REEL via Playwright : `npx playwright test --grep '02' --list` ---
# Liste les tests SANS les executer (--list) et SANS navigateur.
# Ne tourne que si npx + node_modules sont presents ; sinon repli propre.
def lister_tests_module(serie: Path, grep: str = "02"):
    if shutil.which("npx") is None:
        return None, "npx introuvable — `npm install` requis (repli : inventaire statique ci-dessus)."
    if not (serie / "node_modules").exists():
        return None, "node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus)."
    try:
        # NB: pas de guillemets autour de {grep} — cmd.exe (Windows) les traite
        # comme litteraux. grep est un token simple (ex. "02"), sans espace.
        r = subprocess.run(
            f"npx playwright test --grep {grep} --list",
            cwd=str(serie), shell=True, capture_output=True, text=True, timeout=180,
        )
        sortie = (r.stdout or "") + (r.stderr or "")
        return r.returncode, _redact(sortie.strip())
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"

code_retour, sortie = lister_tests_module(SERIE, "02")
print("returncode:", code_retour)
print(sortie if sortie else "(aucune sortie)")
returncode: None
node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus).

4. Lancer le module (pour de vrai)

L’inventaire ci-dessus se fait hors-ligne. Pour exécuter les tests contre une vraie instance Open WebUI, il faut un .env configuré (OWUI_URL, OWUI_EMAIL, OWUI_PASSWORD) — jamais commité — puis :

npm install                                  # dependances
npx playwright install chromium              # navigateur
npm run test:module2                         # ce module uniquement
npx playwright test --grep "02" --headed     # navigateur VISIBLE (debug)
PWDEBUG=1 npx playwright test --grep "02" --headed   # Playwright Inspector
npm run report                               # rapport HTML

L’exécution live dépend d’une plateforme et de secrets : elle est différée ici (et constitue le cœur du capstone). Ce notebook reste reproductible partout, sans secret.

# --- Demonstration executable : classer une route par sa zone d'accès ---
# Pas de navigateur : on raisonne sur la *forme* de la route (le concept RBAC du para. 3).
def zone_acces(route: str) -> str:
    r = route.strip()
    if r.startswith("/admin"):
        return "admin"        # RBAC : admin uniquement
    if r.startswith("/workspace"):
        return "workspace"    # utilisateur connecté
    if r.startswith("/channels"):
        return "channels"     # utilisateur connecté
    if r == "/" or r.startswith("/c/"):
        return "chat"         # utilisateur connecté (défaut)
    return "inconnue"

exemples = ["/", "/c/abc-123", "/admin", "/admin/settings",
            "/workspace/models", "/workspace/knowledge", "/channels", "/workspace/prompts"]
for r in sorted(exemples, key=zone_acces):
    print(f"  zone {zone_acces(r):10} : {r}")
  zone admin      : /admin
  zone admin      : /admin/settings
  zone channels   : /channels
  zone chat       : /
  zone chat       : /c/abc-123
  zone workspace  : /workspace/models
  zone workspace  : /workspace/knowledge
  zone workspace  : /workspace/prompts

5. Interpréter les résultats — et le piège du 429

Un run renvoie, pour chaque test, un statut. Sur le module 02, un statut mérite une attention particulière :

  • passed — le test a vérifié ce qu’il devait. ✅
  • failed — une assertion a échoué ou un sélecteur n’a rien trouvé → drift d’interface (libellé FR/EN) ou redirection inattendue (RBAC), à investiguer.
  • skipped — volontairement ignoré (section non configurée, ex. Channels désactivé). Un skip n’est pas une régression.
  • flaky — passe au retry après un premier échec (souvent : plateforme lente).

Piège spécifique au module 02 — le 429 (rate-limiting). Si le storageState est absent ou expiré, un test peut tenter un login de repli et recevoir un 429 Too Many Requests. Ce n’est pas un échec fonctionnel de l’application : c’est le rate-limiter de l’endpoint d’auth qui protège contre le brute-force. Un 429 dans le rapport = problème de setup de test (régénérer le .auth/owui.json), pas une régression d’Open WebUI. Savoir l’identifier évite un no-go injustifié le jour d’une certification.

# --- Qualifier un rapport (echantillon module 02) ---
rapport_exemple = [
    {"test": "session authentifiee persistante", "status": "passed"},
    {"test": "acceder au panneau admin", "status": "passed"},
    {"test": "reglages admin", "status": "failed"},               # ex. : drift de libelle FR/EN
    {"test": "workspace — bases de connaissances", "status": "skipped"},  # KB non configuree
    {"test": "workspace — modeles personnalises", "status": "passed"},
    {"test": "section Channels", "status": "skipped"},            # channels non actives
]

def qualifier(rapport):
    n = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}
    for t in rapport:
        n[t["status"]] = n.get(t["status"], 0) + 1
    return n

bilan = qualifier(rapport_exemple)
print("Bilan module 02 (echantillon) :", bilan)
verdict = "GO" if bilan["failed"] == 0 else "NO-GO (investiguer les failed — ici : drift de libelle admin)"
print("Verdict provisoire :", verdict)
Bilan module 02 (echantillon) : {'passed': 3, 'failed': 1, 'skipped': 2, 'flaky': 0}
Verdict provisoire : NO-GO (investiguer les failed — ici : drift de libelle admin)

Exercices

Trois exercices, tous exécutables tels quels (ils tournent sans erreur et renvoient un placeholder). Remplace le corps de chaque fonction, ré-exécute, et compare au comportement attendu décrit plus haut.

Exercice 1 — Zone d’accès d’une route

Complète zone_d_acces pour qu’elle renvoie "admin", "workspace", "channels", "chat" ou "inconnue" selon le préfixe de la route, sans réutiliser zone_acces du §3. Appuie-toi sur le tableau RBAC du §3.

def zone_d_acces(route: str) -> str:
    # TODO : renvoyer "admin" / "workspace" / "channels" / "chat" / "inconnue"
    # selon le prefixe de la route. Indice : inspecte le debut de la chaîne.
    return "inconnue"  # placeholder — a remplacer

# Test rapide (doit afficher "admin" une fois complete) :
print("zone de '/admin/settings' :", zone_d_acces("/admin/settings"))
zone de '/admin/settings' : inconnue

Exercice 2 — Relier un test à son concept

Complète concept_du_test : à partir du titre d’un test du module 02, renvoie le concept principal qu’il illustre (ex. "storageState", "admin", "workspace", "channels"). Inspire-toi de l’inventaire du §2 et du tableau des routes du §3.

def concept_du_test(titre: str) -> str:
    # TODO : mapper un titre de test -> son concept principal
    # Ex : "acceder au panneau admin..." -> "admin"
    return "a determiner"  # placeholder

for t in ["la session authentifiee est valide et persistante",
          "acceder au panneau admin — liste des utilisateurs",
          "workspace — lister les prompts",
          "acceder a la section Channels"]:
    print(f"  {t!r} -> {concept_du_test(t)}")
  'la session authentifiee est valide et persistante' -> a determiner
  'acceder au panneau admin — liste des utilisateurs' -> a determiner
  'workspace — lister les prompts' -> a determiner
  'acceder a la section Channels' -> a determiner

Exercice 3 — L’assertion manquante

Le spec laisse un exercice en commentaire (ex. « comptez le nombre d’utilisateurs dans la table admin »). Une assertion Playwright typique sur la route admin vérifie l’URL atteinte. En TypeScript ce serait await expect(page).toHaveURL(/\/admin/);. Ici, renvoie cette ligne sous forme de chaîne depuis assertion_url_admin() (on valide la forme, pas l’exécution navigateur).

def assertion_url_admin() -> str:
    # TODO : renvoyer la ligne d'assertion Playwright verifiant l'URL admin
    # Forme attendue : await expect(page).toHaveURL(/\/admin/);
    return "a completer"  # placeholder

print(assertion_url_admin())
a completer

Conclusion & étape suivante

Tu sais désormais sécuriser l’accès (storageState), raisonner sur le RBAC et les routes SvelteKit, gérer les libellés multilingues et — crucial — distinguer un 429 (rate-limiting) d’une vraie régression. La porte d’entrée de la flotte est certifiée.

➡️ Étape 3 — Module 03 : Chat & Streaming. Tu y attaqueras le cœur métier : le chat IA en streaming, son non-déterminisme, et les assertions tolérantes qui vont avec.


Note de reproductibilité (C.3). Les cellules de ce notebook ont été exécutées hors-ligne : lecture du .spec.ts, inventaire --list (sans navigateur) et démonstrations pure-Python. Aucune ne requiert d’instance Open WebUI ni de secret. L’exécution live des tests E2E (navigateur + plateforme) est volontairement différée au capstone ; la suite a depuis été revalidée sur la flotte v0.11.0 (campagne #9854, 2026-08-10), dont les pièges mesurés (redirection /admin/settings, payload chat) sont documentés dans WHATS-NEW-v0.11.md. Aucun claim de compatibilité au-delà de v0.11.0 n’est porté par ce module.

Retour au sommet