Étape 5 (finale) de la mission « QA Engineer d’une flotte GenAI multi-tenant ».« Automatise tout, et prouve que les tenants ne se parlent pas. »
Ce notebook est la couche pédagogique du module 05 — le module final. Il quitte le navigateur pour l’API REST, explore l’architecture multi-tenant d’Open WebUI (isolation et partage des données), et ouvres sur l’industrialisation CI/CD. Il raconte le module, lit le banc de tests réel (05-api-multi-tenant.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 s’exécutent sans navigateur ni instance Open WebUI : elles lisent le code des tests et raisonnent sur les concepts (tests API vs UI, isolation multi-tenant, CI/CD). L’exécution live (deux tenants configurés) est documentée au §4 et constitue le capstone final.
1. Objectifs & place dans la mission
La mission compte cinq étapes. Tu es à la cinquième et dernière : les fonctionnalités avancées sont certifiées (module 04), on attaque maintenant l’isolation multi-tenant et l’industrialisation. C’est le module qui transforme une suite de tests artisanale en un harnais d’entreprise, et qui garantit le contrat de fond d’une flotte multi-tenant : un tenant ne fuit jamais chez un autre.
À la fin de ce module, tu sais :
tester une API REST directement avec Playwright (APIRequestContext), sans navigateur ;
utiliser le pattern beforeAll + token partagé pour éviter le rate-limiting (429) ;
vérifier l’isolation et le partage des données dans une architecture multi-tenant ;
comparer des données entre instances (parité des modèles, cross-tenant) ;
intégrer la suite Playwright dans un pipeline CI/CD (GitHub Actions).
Étape
Module
Ce que tu certifies
1
01 — Découverte
Mon outillage fonctionne, je sais lire un test.
2
02 — Navigation & Auth
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 (ici)
L’isolation + l’industrialisation.
# --- Setup : localiser la serie et le module, SANS exposer de chemin absolu ---from pathlib import Pathimport re, shutil, subprocessdef _redact(texte: str) ->str:# Anti-fuite : ne jamais afficher de chemin absolu (machine de l'auteur, home...). out = textefor absolu in {str(Path.cwd().resolve()), str(Path.home())}: out = out.replace(absolu, ".") out = out.replace(absolu.replace("/", "\\"), ".")return outdef 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 candfor sub in Path.cwd().resolve().rglob("playwright.config.ts"):return sub.parentraiseFileNotFoundError("playwright.config.ts introuvable depuis le dossier courant")SERIE = trouver_racine_serie()MODULE = SERIE /"05-multi-tenant-ci"SPEC = MODULE /"05-api-multi-tenant.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 : 05-multi-tenant-ci
Banc de test: 05-api-multi-tenant.spec.ts (present)
2. Tests API vs tests UI — et le pattern beforeAll + token partagé
Playwright n’est pas limité au navigateur. Sa classe APIRequestContext permet de faire des requêtes HTTP directes — plus rapides et plus fiables que les tests UI quand on cherche à valider des données ou de la logique métier (pas un rendu visuel).
// Test UI (lent, teste le rendu)await page.goto('/admin');awaitexpect(page.getByText('Users')).toBeVisible();// Test API (rapide, teste les données)const response =await request.get('/api/v1/users', { headers: { Authorization:`Bearer ${token}` } });const users =await response.json();expect(users.length).toBeGreaterThan(0);
Quand choisir l’API ? Comparer des données entre instances, vérifier l’isolation, tester l’authentification à grande échelle. Quand choisir le navigateur ? Vérifier un rendu visuel, le streaming, l’expérience utilisateur.
Le pattern beforeAll + token partagé (anti-429). L’endpoint /api/v1/auths/signin a un rate-limit strict (~2 min entre appels). Si chaque test faisait son login → 429 avant la fin. La solution : s’authentifier une seule fois dans un hook test.beforeAll(), stocker le token JWT, et le réutiliser dans tous les tests. C’est le pattern canonique des tests API : authentification coûteuse → beforeAll ; requêtes rapides → chaque test.
let token ='';test.beforeAll(async ({ request }) => { // UNE seule authentification token =awaitapiLogin(request, OWUI_URL, email, password);});test('lister les modèles',async ({ request }) => { // reutilise le tokenconst models =awaitgetModels(request, OWUI_URL, token);});
# --- Lire le banc de tests et en extraire la structure (sans le lancer) ---texte = SPEC.read_text(encoding="utf-8")# Titres des tests : test('....', ...) — guillemets simplestitres = re.findall(r"test\('([^']+)'", texte)# Le module 05 contient PLUSIEURS blocs describe (un par partie) — on les liste tousdescribes = re.findall(r"describe\('([^']+)'", texte)print(f"{len(describes)} partie(s) (describe) dans {SPEC.name} :")for i, d inenumerate(describes, 1):print(f" {i}. {d}")print()print(f"{len(titres)} test(s) defini(s) au total :")for i, t inenumerate(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.")
2 partie(s) (describe) dans 05-api-multi-tenant.spec.ts :
1. 05a — Tests API (tenant principal)
2. 05b — Multi-tenant : Isolation & Partage
8 test(s) defini(s) au total :
1. login via API — token JWT valide
2. lister les modeles disponibles via API
3. lister les knowledge bases via API
4. lister les fonctions installees via API
5. les deux tenants sont accessibles
6. knowledge bases partagees entre tenants
7. modeles custom identiques sur les deux tenants
8. isolation des donnees — bases utilisateurs separees
2 exercice(s) embarque(s) dans les commentaires du spec.
3. Architecture multi-tenant — isolation et partage
Un tenant est une instance indépendante d’Open WebUI. La flotte MyIA en héberge plusieurs (étudiants, démos, prod). Le contrat de fond à certifier : un tenant ne fuit pas chez un autre. Deux aspects cohabitent :
Isolation. Chaque tenant a sa propre base PostgreSQL (utilisateurs, conversations, config séparés). Le test d’isolation vérifie qu’un token émis sur le tenant 1 est rejeté sur le tenant 2 — preuve que les bases d’utilisateurs sont distinctes.
Partage. Certaines ressources sont communes : les Knowledge Bases (via un Qdrant partagé) et les modèles LLM (déployés par script sur tous les tenants). Le test de parité vérifie que les modèles custom sont identiques sur les deux tenants.
// Isolation : le token du tenant 1 doit ETRE REJETE sur le tenant 2try {awaitgetModels(request, TENANT2_URL, token1);// token du tenant 1 sur le tenant 2console.log('⚠️ Token cross-tenant accepte — isolation a verifier');} catch {console.log('✓ Token cross-tenant rejete — isolation confirmee');}
Piège multi-tenant. Les tests de la partie 2 (05b) nécessitent un second tenant configuré (OWUI_TENANT2_* dans le .env). Sans lui, ils se skipperont proprement (test.skip(!TENANT2_URL, 'Tenant 2 non configure')) — c’est sain sur un environnement mono-tenant, mais l’isolation ne peut être réellement certifiée que sur une flotte à deux tenants (scénario du capstone).
# --- Inventaire REEL via Playwright : `npx playwright test --grep '05' --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="05"):if shutil.which("npx") isNone:returnNone, "npx introuvable — `npm install` requis (repli : inventaire statique ci-dessus)."ifnot (serie /"node_modules").exists():returnNone, "node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus)."try: 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())exceptExceptionas e:returnNone, f"{type(e).__name__}: {e}"code_retour, sortie = lister_tests_module(SERIE, "05")print("returncode:", code_retour)print(sortie if sortie else"(aucune sortie)")
Pour exécuter, un .env avec au minimum le tenant principal ; le second tenant est optionnel (partie 05b skippée sinon) :
OWUI_URL=... # tenant principalOWUI_EMAIL=...OWUI_PASSWORD=...OWUI_TENANT2_URL=... # OPTIONNEL — active les tests multi-tenant (partie 05b)OWUI_TENANT2_EMAIL=...OWUI_TENANT2_PASSWORD=...
Puis :
npm installnpx playwright install chromiumnpm run test:module5 # ce module uniquementnpx playwright test --grep"05"--headed# (peu utile ici : tests surtout API)npm run report # rapport HTML
Industrialisation (CI/CD). La suite entière (modules 01-05) s’intègre dans un pipeline GitHub Actions qui la rejoue à chaque push/pull_request. Bonnes pratiques :
Secrets — jamais de credentials en dur : OWUI_* viennent des secrets CI.
Retries — 1-2 retries en CI (réseaux instables, latence LLM).
Rapport — uploader le rapport HTML comme artefact (if: always()).
Timeouts — plus généreux en CI (machines plus lentes).
Screenshots — only-on-failure pour le débogage post-mortem.
L’exécution live suppose une flotte à deux tenants configurée — c’est le capstone final. Ce notebook reste reproductible partout, sans instance ni secret.
# --- Demonstration executable : API ou navigateur ? ---# Pas de navigateur : on raisonne sur le *scenario* de test (concept du para. 2).# L'API (APIRequestContext) est rapide/fiable pour les données ; le navigateur# est nécessaire pour le rendu visuel et l'expérience utilisateur.def approche_recommandee(scenario: str) ->str: s = scenario.lower()ifany(k in s for k in ["comparer", "compar", "isolation", "token", "jwt", "parite", "api"]):return"api"# données / logique métier → APIRequestContextifany(k in s for k in ["rendu", "visuel", "streaming", "affiche", "menu", "bouton"]):return"navigateur"# expérience utilisateur → pagereturn"api ou navigateur"exemples = ["Comparer les modeles entre deux tenants", # api (données cross-instance)"Verifier le rendu d un bloc de code Markdown", # navigateur (rendu visuel)"Verifier que le token du tenant 1 est rejete", # api (isolation)"Tester le streaming d une reponse LLM", # navigateur (expérience)]for sc in exemples:print(f" {approche_recommandee(sc):18} : {sc}")
api : Comparer les modeles entre deux tenants
navigateur : Verifier le rendu d un bloc de code Markdown
api : Verifier que le token du tenant 1 est rejete
navigateur : Tester le streaming d une reponse LLM
5. Interpréter les résultats — tenant2 absent, et le verdict multi-tenant
Le module 05 a une particularité : sa seconde moitié (05b) se skippe sur un environnement mono-tenant. Lire le rapport demande donc de séparer deux familles :
Partie 05a (API, tenant principal) — doit être passed sur toute instance configurée. Un échec ici = problème d’authentification, d’API, ou de token (vérifier le .env et le rate-limiting).
Partie 05b (multi-tenant) — skipped tant que OWUI_TENANT2_* n’est pas configuré. Un skip n’est pas une régression : c’est le contrat du test. MAIS — l’isolation ne peut être certifiée que quand 05b tourne réellement sur deux tenants.
Piège spécifique au module 05 — le verdict d’isolation. Un rapport mono-tenant (05b skipped) ne certifie pas l’isolation — il la remet à plus tard. Le jour de la certification finale (capstone), 05b doit tourner sur deux tenants et montrer : (a) les KBs/models partagés visibles des deux côtés, (b) le token cross-tenant rejeté. Tant que 05b est skippé, le verdict multi-tenant est « non certifié (mono-tenant) », pas « GO ».
C’est la nuance cruciale : un test skippé par configuration n’est pas un échec, mais il n’est pas non plus une preuve de réussite. Le capstone est là pour transformer les skips en preuves.
# --- Qualifier un rapport (echantillon module 05 — mono-tenant) ---rapport_exemple = [ {"test": "login via API — token JWT valide", "status": "passed"}, {"test": "lister les modeles disponibles via API", "status": "passed"}, {"test": "lister les knowledge bases via API", "status": "passed"}, {"test": "lister les fonctions installees via API", "status": "passed"}, {"test": "les deux tenants sont accessibles", "status": "skipped"}, # TENANT2 non configure {"test": "knowledge bases partagees entre tenants", "status": "skipped"}, {"test": "modeles custom identiques sur les deux tenants", "status": "skipped"}, {"test": "isolation des donnees — bases utilisateurs separees", "status": "skipped"},]def qualifier(rapport): n = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}for t in rapport: n[t["status"]] = n.get(t["status"], 0) +1return nbilan = qualifier(rapport_exemple)print("Bilan module 05 (mono-tenant) :", bilan)# 4 passed (API tenant1) + 4 skipped (tenant2 absent) + 0 failedverdict ="GO (tenant principal OK) — isolation NON CERTIFIEE (05b skipped, capstone requis)"print("Verdict provisoire :", verdict)
Bilan module 05 (mono-tenant) : {'passed': 4, 'failed': 0, 'skipped': 4, 'flaky': 0}
Verdict provisoire : GO (tenant principal OK) — isolation NON CERTIFIEE (05b skipped, capstone requis)
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 — Décoder un JWT (base64)
Le spec laisse en commentaire : « Décodez le JWT (base64) pour voir son contenu ». Un JWT a trois parties séparées par des points (header.payload.signature), chacune encodée en base64-url. Complète decoder_payload_jwt(token) pour qu’elle renvoie le payload (2ᵉ partie) décodé en dictionnaire Python. Appuie-toi sur l’indice du spec : json.loads(base64.urlsafe_b64decode(payload + padding)).
import base64, jsondef decoder_payload_jwt(token: str) ->dict:# TODO : extraire la 2eme partie du JWT (apres le 1er point), la decoder# en base64-url (avec padding), et la parser en JSON.# Indice : partie = token.split('.')[1] ; ajouter du padding '==' si besoin.return {} # placeholder — a remplacer# Test rapide avec un faux JWT (payload {"sub":"t1","role":"admin"} en base64-url) :faux_jwt ="header."+ base64.urlsafe_b64encode(b'{"sub":"t1","role":"admin"}').decode().rstrip('=') +".sig"print("payload decode :", decoder_payload_jwt(faux_jwt))
payload decode : {}
Exercice 2 — Modèles présents sur T1 mais absents de T2
Le spec laisse en commentaire : « Identifiez les modèles présents sur T1 mais absents de T2 ». C’est une différence d’ensembles. Complète modeles_presents_sur_t1_seulement(ids_t1, ids_t2) pour qu’elle renvoie la liste des IDs présents dans ids_t1 mais pas dans ids_t2. Inspire-toi de l’indice du spec (Set + filter).
def modeles_presents_sur_t1_seulement(ids_t1: list, ids_t2: list) ->list:# TODO : renvoyer les IDs de ids_t1 absents de ids_t2.# Indice : convertir ids_t2 en set, puis filtrer ids_t1.return [] # placeholder — a remplacer# Test rapide (doit afficher ['m1'] une fois complete) :print("sur T1 seulement :", modeles_presents_sur_t1_seulement(['m1', 'm2', 'm3'], ['m2', 'm3', 'm4']))
sur T1 seulement : []
Exercice 3 — API ou navigateur ?
Le choix API vs navigateur est le réflexe fondateur du module 05 (cf §2). Complète approche_du_scenario pour qu’elle renvoie "api" ou "navigateur" selon le scénario de test, sans réutiliser approche_recommandee du §3. Appuie-toi sur la table du §2 (comparer des données → API ; vérifier un rendu visuel → navigateur).
def approche_du_scenario(scenario: str) ->str:# TODO : renvoyer "api" ou "navigateur" selon le scenario.# Indice : mots-cles données/comparer/isolation -> api ; rendu/visuel -> navigateur.return"api"# placeholder — a affinerfor s in ["Verifier l isolation des utilisateurs entre tenants","Verifier le rendu d un bloc de code dans le chat","Comparer les knowledge bases de deux instances","Tester le bouton Regenerer une reponse"]:print(f" {approche_du_scenario(s):12} : {s}")
api : Verifier l isolation des utilisateurs entre tenants
api : Verifier le rendu d un bloc de code dans le chat
api : Comparer les knowledge bases de deux instances
api : Tester le bouton Regenerer une reponse
Conclusion — fin de la mission
Tu sais désormais tout certifier sur une flotte GenAI multi-tenant : l’outillage (01), l’accès et l’admin (02), le cœur métier du chat IA (03), les fonctions avancées RAG/outils/canaux (04), et enfin l’isolation multi-tenant + l’industrialisation CI/CD (05). Tu as surtout intégré les deux réflexes transversaux de toute la série : un skip par configuration n’est ni un échec ni une preuve, et l’API teste les données là où le navigateur teste l’expérience.
➡️ Capstone. Les cinq modules ne sont que la lecture de la suite. Le capstone final rejoue toute la série sur une vraie flotte : une instance Open WebUI riche (KBs, outils MCP, canaux, modèles cloud + local), deux tenants configurés (pour certifier l’isolation), puis la rédaction du rapport go/no-go de certification. C’est là que les skips deviennent des preuves, et que le QA Engineer livre son verdict.
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, de secret, de second tenant ni d’appel API live. L’exécution live des tests E2E (flotte à deux tenants) est le capstone final et sera revalidée en Phase 3 (Open WebUI v0.9.6). Aucun claim de compatibilité v0.9.6 n’est porté par ce module.