Ajouter validation automatisee Playwright

This commit is contained in:
Daniel Allaire 2026-06-02 09:53:22 -04:00
parent 4538d04972
commit ffa70fcc04
11 changed files with 1521 additions and 3 deletions

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
node_modules/
dist/
__pycache__/
.tmp/
frontend/test-results/
frontend/playwright-report/
*.py[cod]
*.retry
ansible/group_vars/vault.yml

View file

@ -12,6 +12,8 @@ Consignes pour les agents IA qui modifient ce dépôt. Ce fichier est une bousso
- Exploitation: `docs/90_EXPLOITATION.md`
- Déploiement Ansible: `docs/91_DEPLOIEMENT_ANSIBLE.md`
- Multi-groupe: `docs/93_MULTI_GROUPE.md`
- Plan de validation: `docs/96_PLAN_VALIDATION.md`
- Validation automatisée: `docs/97_VALIDATION_AUTOMATISEE.md`
- Garde-fous sociaux: `docs/94_GARDE_FOUS_SOCIAUX.md`
Lire les documents pertinents avant un changement large. Ne pas recopier leur contenu ici.
@ -79,9 +81,11 @@ Chaque groupe a un `tenant_schema` PostgreSQL. La voie applicative principale ut
Choisir les validations selon le changement:
```bash
scripts/validate.sh
python3 -m py_compile <fichiers_python>
.venv/bin/python -m pytest backend/tests/test_business_rules.py
.venv/bin/python -m pytest backend/tests
npm run build
(cd frontend && npm run e2e)
ansible-playbook ansible/site.yml --syntax-check
ansible-playbook ansible/verify.yml --syntax-check
ansible-playbook ansible/backup-now.yml --syntax-check

View file

@ -0,0 +1,108 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
class ValidationPlanTests(unittest.TestCase):
def test_validation_plan_is_indexed_and_structured(self):
index = read("docs/00_INDEX.md")
plan = read("docs/96_PLAN_VALIDATION.md")
self.assertIn("96_PLAN_VALIDATION.md", index)
self.assertGreaterEqual(plan.count("Scénario:"), 40)
self.assertEqual(plan.count("Scénario:"), plan.count("Résultats attendus:"))
def test_validation_plan_covers_all_critical_domains(self):
plan = read("docs/96_PLAN_VALIDATION.md")
for section in [
"Accès, groupes et identité",
"Navigation et ergonomie",
"Membres",
"Postes, mandats et permissions",
"Rencontres",
"Assemblées",
"Gouvernance",
"Contributions",
"Dépenses",
"Trésorerie",
"Inventaires, littérature et jetons",
"Événements",
"Rapports PDF et mode papier",
"Journal, historique et notifications",
"Sauvegarde, restauration et exploitation",
"Concurrence et changements simultanés",
]:
with self.subTest(section=section):
self.assertIn(f"## {section}", plan)
class PublicLandingContractTests(unittest.TestCase):
def test_public_landing_contains_only_public_links_and_sysadmin_access(self):
login = read("frontend/src/pages/Login.jsx")
self.assertIn("Choisir un groupe", login)
self.assertIn("Sysadmin", login)
self.assertIn("Charte", login)
self.assertIn("Confidentialité", login)
self.assertIn("Découvrir", login)
self.assertIn("setPage('G14')", login)
self.assertIn("localStorage.removeItem('gm_groupe_id')", login)
self.assertIn('type="password"', login)
def test_group_home_does_not_expose_instance_admin_or_public_policy_links(self):
accueil = read("frontend/src/pages/Accueil.jsx")
for forbidden in ["setPage('G14')", "Charte", "Confidentialité", "Découvrir"]:
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, accueil)
class SafetyContractTests(unittest.TestCase):
def test_api_client_sends_resource_version_for_concurrency_protection(self):
api = read("frontend/src/stores/api.js")
self.assertIn("options.version", api)
self.assertIn("X-Resource-Version", api)
def test_crystallized_reports_guard_meeting_inputs(self):
guarded_files = [
"backend/app/routers/reunions.py",
"backend/app/routers/presences.py",
"backend/app/routers/pv.py",
"backend/app/routers/ventes_litterature.py",
"backend/app/routers/jetons.py",
]
for rel in guarded_files:
source = read(rel)
with self.subTest(router=rel):
self.assertIn("verifier_reunion_non_cristallisee", source)
def test_group_backup_and_restore_are_group_scoped_operations(self):
admin_router = read("backend/app/routers/admin.py")
gestion_groupes = read("frontend/src/pages/GestionGroupes.jsx")
self.assertIn("/groupes/{groupe_id}/backup", admin_router)
self.assertIn("/groupes/{groupe_id}/restore", admin_router)
self.assertIn("creer_sauvegarde_groupe", admin_router)
self.assertIn("restaurer_sauvegarde_groupe", admin_router)
self.assertIn("Réinit", gestion_groupes)
self.assertIn("Restaurer", gestion_groupes)
def test_instance_admin_token_is_kept_out_of_group_context(self):
admin_router = read("backend/app/routers/admin.py")
security = read("backend/app/core/security.py")
self.assertIn("create_token(str(admin.id), None", admin_router)
self.assertIn('subject_type="instance_admin"', admin_router)
self.assertIn('payload.get("typ") == "instance_admin"', security)
self.assertIn("return admin", security)
if __name__ == "__main__":
unittest.main()

View file

@ -23,6 +23,8 @@ Pour administrer ou exploiter l'application:
- [Déploiement](92_DEPLOIEMENT.md)
- [Sécurité et confidentialité](80_SECURITE.md)
- [Multi-groupe](93_MULTI_GROUPE.md)
- [Plan de validation fonctionnelle](96_PLAN_VALIDATION.md)
- [Validation automatisée](97_VALIDATION_AUTOMATISEE.md)
Pour maintenir ou faire évoluer le code:
@ -31,6 +33,8 @@ Pour maintenir ou faire évoluer le code:
- [API applicative](70_API.md)
- [Règles métier](30_REGLES_METIER.md)
- [Liens de causalité](40_LIENS_CAUSALITE.md)
- [Plan de validation fonctionnelle](96_PLAN_VALIDATION.md)
- [Validation automatisée](97_VALIDATION_AUTOMATISEE.md)
## Organisation fonctionnelle

1008
docs/96_PLAN_VALIDATION.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
# Validation automatisée
Statut: runbook de validation
Public: testeurs, mainteneurs, sysadmin
Dernière révision: 2026-06-02
Ce document explique comment automatiser la validation sans remplacer le jugement humain nécessaire pour l'acceptabilité, la lisibilité et l'ergonomie.
## Commande locale
Depuis la racine du dépôt:
```bash
scripts/validate.sh
```
Cette commande exécute:
- compilation Python du backend et des scripts de déploiement;
- tests backend;
- build frontend;
- tests navigateur Playwright;
- syntax-check Ansible lorsque `ansible-playbook` est disponible.
Pour ignorer temporairement les tests navigateur:
```bash
SKIP_E2E=1 scripts/validate.sh
```
## Ce qui est automatisé
- règles métier testables par code;
- contrats de sécurité et de multi-groupe;
- présence des protections de concurrence;
- cristallisation des rapports;
- exposition correcte de l'accès sysadmin;
- page publique de sélection des groupes;
- accès sysadmin d'instance;
- changement du PIN sysadmin;
- liens publics Charte, Confidentialité et Découvrir;
- build frontend;
- validité syntaxique Ansible.
## Ce qui doit rester validé par un humain
- lisibilité des rapports PDF;
- confort de navigation sur petit téléphone;
- ton des textes destinés aux membres;
- acceptabilité sociale des processus;
- clarté des formulaires papier;
- compréhension par des membres non techniques.
## Ronde de validation recommandée
1. Exécuter `scripts/validate.sh`.
2. Déployer sur une VM vanille.
3. Exécuter `ansible-playbook ansible/verify.yml`.
4. Réinitialiser `Groupe Démonstration`.
5. Parcourir les scénarios du [plan de validation fonctionnelle](96_PLAN_VALIDATION.md).
6. Noter les écarts avec la page, l'action, le résultat obtenu et le résultat attendu.
## Tests Playwright
Les tests Playwright sont dans `frontend/e2e`.
Ils démarrent automatiquement Vite, simulent les réponses API nécessaires et exécutent les scénarios dans un navigateur mobile et desktop.
Commande directe:
```bash
cd frontend
npm run e2e
```
Pour installer le navigateur Chromium si nécessaire:
```bash
cd frontend
npx playwright install chromium
```
## Évolution prévue
La prochaine étape pertinente est d'élargir Playwright vers les parcours authentifiés complets:
- sélection de groupe;
- connexion membre et sysadmin;
- navigation mobile;
- rencontre complète;
- assemblée complète;
- trésorerie;
- journal;
- captures de rapports PDF.
Ces tests devront être ajoutés progressivement pour éviter une suite fragile.

155
frontend/e2e/public.spec.js Normal file
View file

@ -0,0 +1,155 @@
import { expect, test } from '@playwright/test';
const groupes = [
{
id: 'groupe-alpha',
nom: 'Groupe Alpha',
district: '87-16',
adresse_local: '123 rue Principale, Saint-Bruno',
jour_reunion: 'mardi',
heure_reunion: '19:30',
assemblee_affaires_ordre: 'premiere',
format_reunion: 'Discussion ouverte',
},
{
id: 'groupe-demo',
nom: 'Groupe Démonstration',
district: '87-16',
adresse_local: 'Salle de démonstration',
jour_reunion: 'samedi',
heure_reunion: '20:00',
assemblee_affaires_ordre: 'derniere',
format_reunion: 'Formation',
},
];
async function mockPublicApi(page) {
await page.route('**/api/auth/groupes', async (route) => {
await route.fulfill({ json: groupes });
});
}
async function mockSysadminApi(page) {
await page.route('**/api/admin/sysadmin-login', async (route) => {
const body = route.request().postDataJSON();
if (body.pin !== '0000') {
await route.fulfill({ status: 401, json: { detail: 'PIN Sysadmin invalide' } });
return;
}
await route.fulfill({
json: {
access_token: 'instance-admin-token',
token_type: 'bearer',
sysadmin_instance: true,
membre: {
id: 'admin-id',
prenom: 'Sysadmin',
courriel: 'sysadmin',
groupe_id: null,
date_abstinence: null,
is_sysadmin: true,
},
},
});
});
await page.route('**/api/admin/groupes', async (route) => {
await route.fulfill({
json: groupes.map((groupe, index) => ({
...groupe,
lien_aa87: 'https://aa87.org',
tenant_schema: `grp_${index}`,
cree_le: '2026-01-01T00:00:00Z',
membres: index === 0 ? 12 : 14,
reunions: index === 0 ? 52 : 52,
traces: index === 0 ? 120 : 180,
demo: groupe.nom === 'Groupe Démonstration',
protege: groupe.nom === 'Groupe Démonstration',
})),
});
});
await page.route('**/api/admin/sysadmin-pin', async (route) => {
const body = route.request().postDataJSON();
if (body.pin_actuel !== '0000') {
await route.fulfill({ status: 401, json: { detail: 'PIN actuel invalide' } });
return;
}
await route.fulfill({ json: { message: 'PIN sysadmin mis à jour' } });
});
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.clear();
sessionStorage.clear();
});
await mockPublicApi(page);
});
test('la page publique liste les groupes en tuiles fermées et expose les liens publics', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Choisir un groupe' })).toBeVisible();
await expect(page.getByRole('button', { name: /Groupe Alpha/ })).toBeVisible();
await expect(page.getByText('123 rue Principale')).toBeHidden();
await expect(page.getByRole('button', { name: 'Sysadmin' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Charte' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Confidentialité' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Découvrir' })).toBeVisible();
await page.getByRole('button', { name: /Groupe Alpha/ }).click();
await expect(page.getByText('123 rue Principale')).toBeVisible();
await expect(page.getByPlaceholder('Adresse courriel ou identifiant')).toBeVisible();
await expect(page.getByPlaceholder('PIN')).toHaveAttribute('type', 'password');
});
test('les pages publiques Charte et Confidentialité restent accessibles sans connexion', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Charte' }).click();
await expect(page.getByRole('heading', { name: /Charte|Principes/ })).toBeVisible();
await page.goto('/');
await page.getByRole('button', { name: 'Confidentialité' }).click();
await expect(page.getByRole('heading', { name: /Confidentialité/ })).toBeVisible();
});
test('le sysadmin d’instance ouvre la gestion des groupes sans contexte de groupe', async ({ page }) => {
await mockSysadminApi(page);
await page.goto('/');
await page.getByRole('button', { name: 'Sysadmin' }).click();
await page.getByPlaceholder('PIN sysadmin').fill('0000');
await page.getByRole('button', { name: 'Ouvrir' }).click();
await expect(page.getByRole('heading', { name: 'Gestion des groupes' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Créer un groupe' })).toBeVisible();
await expect(page.getByRole('button', { name: /Groupe Alpha/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Groupe Démonstration/ })).toBeVisible();
await expect(page.getByText('Instance · Bonjour Sysadmin')).toBeVisible();
await expect(page.getByText(/Groupe Alpha · Bonjour Sysadmin/)).not.toBeVisible();
});
test('le changement de PIN sysadmin masque les saisies et exige une confirmation', async ({ page }) => {
await mockSysadminApi(page);
await page.goto('/');
await page.getByRole('button', { name: 'Sysadmin' }).click();
await page.getByPlaceholder('PIN sysadmin').fill('0000');
await page.getByRole('button', { name: 'Ouvrir' }).click();
await page.getByRole('button', { name: 'Changer le PIN sysadmin' }).click();
await expect(page.getByPlaceholder('PIN actuel')).toHaveAttribute('type', 'password');
await expect(page.getByPlaceholder('Nouveau PIN', { exact: true })).toHaveAttribute('type', 'password');
await expect(page.getByPlaceholder('Confirmer le nouveau PIN')).toHaveAttribute('type', 'password');
await page.getByPlaceholder('PIN actuel').fill('0000');
await page.getByPlaceholder('Nouveau PIN', { exact: true }).fill('1234');
await page.getByPlaceholder('Confirmer le nouveau PIN').fill('4321');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('La confirmation ne correspond pas')).toBeVisible();
await page.getByPlaceholder('Confirmer le nouveau PIN').fill('1234');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('PIN sysadmin mis à jour')).toBeVisible();
});

View file

@ -13,6 +13,7 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@ -1999,6 +2000,21 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -4753,6 +4769,50 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View file

@ -7,7 +7,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"react": "^18.3.1",
@ -15,12 +16,13 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.0"
}

View file

@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'mobile-chromium',
use: {
browserName: 'chromium',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 3,
userAgent: devices['iPhone 13'].userAgent,
},
},
{
name: 'desktop-chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

41
scripts/validate.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
mkdir -p .tmp/ansible-local .tmp/ansible-remote
export ANSIBLE_LOCAL_TEMP="$ROOT/.tmp/ansible-local"
export ANSIBLE_REMOTE_TEMP="$ROOT/.tmp/ansible-remote"
echo "== Python syntax =="
python3 -m py_compile $(find backend/app deploy -name '*.py' -print)
echo "== Backend tests =="
if [ -x ".venv/bin/python" ]; then
.venv/bin/python -m pytest backend/tests
else
python3 -m pytest backend/tests
fi
echo "== Frontend build =="
(cd frontend && npm run build)
if [ "${SKIP_E2E:-0}" = "1" ]; then
echo "== Playwright E2E =="
echo "SKIP_E2E=1; tests navigateur ignorés."
else
echo "== Playwright E2E =="
(cd frontend && npm run e2e)
fi
if command -v ansible-playbook >/dev/null 2>&1; then
echo "== Ansible syntax =="
ansible-playbook ansible/site.yml --syntax-check
ansible-playbook ansible/verify.yml --syntax-check
ansible-playbook ansible/backup-now.yml --syntax-check
else
echo "== Ansible syntax =="
echo "ansible-playbook introuvable; syntax-check ignoré."
fi
echo "Validation locale terminée."