test(webauthn): E2E authenticateur virtuel, audit a11y à 15 écrans, docsUnverified
e2e/passkey.mjs (parcours complet : ajout refusé/réussi, login usernameless, renommage, suppression), écran securite dans l'audit axe, WEBAUTHN_ORIGIN et la page Sécurité documentées dans CLAUDE.md.
6cfca47 parent: 4f5b01c modified
a11y/audit.mjs +5 -2 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. |
| 2 | 2 | // |
| 3 | -// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue | |
| 3 | +// Parcourt les 15 écrans de l'app (viewport mobile 390×844) et échoue | |
| 4 | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | 5 | // les violations moindres sont listées en avertissement. |
| 6 | 6 | // |
| @@ -82,12 +82,15 @@ const pages = [ | ||
| 82 | 82 | ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` }, |
| 83 | 83 | { name: 'settings', url: '/parametres' }, |
| 84 | 84 | { name: 'categories', url: '/parametres/categories' }, |
| 85 | + // La section clés d'accès n'apparaît que si le serveur audité tourne avec | |
| 86 | + // WEBAUTHN_ORIGIN — sinon seule la partie mot de passe est auditée. | |
| 87 | + { name: 'securite', url: '/parametres/securite' }, | |
| 85 | 88 | { name: 'team-sheet', url: '/temps/nouveau#equipes' }, |
| 86 | 89 | { name: 'todos', url: '/taches' }, |
| 87 | 90 | { name: 'todo-new', url: '/taches/nouvelle' }, |
| 88 | 91 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, |
| 89 | 92 | ].filter(Boolean); |
| 90 | -if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | |
| 93 | +if (pages.length < 15) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | |
| 91 | 94 | |
| 92 | 95 | let blocking = 0; |
| 93 | 96 | for (const p of pages) { |
| @@ -1,6 +1,6 @@ | |||
| 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. | 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. |
| 2 | // | 2 | // |
| 3 | -// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue | 3 | +// Parcourt les 15 écrans de l'app (viewport mobile 390×844) et échoue |
| 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | // les violations moindres sont listées en avertissement. | 5 | // les violations moindres sont listées en avertissement. |
| 6 | // | 6 | // |
| @@ -82,12 +82,15 @@ const pages = [ | |||
| 82 | ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` }, | 82 | ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` }, |
| 83 | { name: 'settings', url: '/parametres' }, | 83 | { name: 'settings', url: '/parametres' }, |
| 84 | { name: 'categories', url: '/parametres/categories' }, | 84 | { name: 'categories', url: '/parametres/categories' }, |
| 85 | + // La section clés d'accès n'apparaît que si le serveur audité tourne avec | ||
| 86 | + // WEBAUTHN_ORIGIN — sinon seule la partie mot de passe est auditée. | ||
| 87 | + { name: 'securite', url: '/parametres/securite' }, | ||
| 85 | { name: 'team-sheet', url: '/temps/nouveau#equipes' }, | 88 | { name: 'team-sheet', url: '/temps/nouveau#equipes' }, |
| 86 | { name: 'todos', url: '/taches' }, | 89 | { name: 'todos', url: '/taches' }, |
| 87 | { name: 'todo-new', url: '/taches/nouvelle' }, | 90 | { name: 'todo-new', url: '/taches/nouvelle' }, |
| 88 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, | 91 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, |
| 89 | ].filter(Boolean); | 92 | ].filter(Boolean); |
| 90 | -if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | 93 | +if (pages.length < 15) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); |
| 91 | 94 | ||
| 92 | let blocking = 0; | 95 | let blocking = 0; |
| 93 | for (const p of pages) { | 96 | for (const p of pages) { |
added
e2e/passkey.mjs +155 -0 | new file mode 100644 | ||
| @@ -0,0 +1,155 @@ | ||
| 1 | +// Parcours complet des clés d'accès (WebAuthn) avec l'authenticateur virtuel | |
| 2 | +// de Chrome (CDP) : ajout refusé sans le bon mot de passe, ajout, déconnexion, | |
| 3 | +// connexion par clé (usernameless), renommage, suppression. | |
| 4 | +// | |
| 5 | +// Prérequis : un serveur exego local lancé avec la BASE DEV et | |
| 6 | +// WEBAUTHN_ORIGIN=http://localhost:8090 (sans elle, la section clés n'existe | |
| 7 | +// pas et le script sort en 2). Le module puppeteer-core est résolu depuis | |
| 8 | +// a11y/ (npm install là-bas d'abord). | |
| 9 | +// | |
| 10 | +// Usage : | |
| 11 | +// CHROME=/usr/host/bin/google-chrome-stable TT_USER=… TT_PASS=… node e2e/passkey.mjs | |
| 12 | + | |
| 13 | +import { execSync } from 'node:child_process'; | |
| 14 | +import { createRequire } from 'node:module'; | |
| 15 | + | |
| 16 | +const require = createRequire(new URL('../a11y/package.json', import.meta.url)); | |
| 17 | +const puppeteer = require('puppeteer-core'); | |
| 18 | + | |
| 19 | +const BASE = process.env.TT_URL ?? 'http://localhost:8090'; | |
| 20 | +const USER = process.env.TT_USER; | |
| 21 | +const PASS = process.env.TT_PASS; | |
| 22 | +if (!USER || !PASS) { | |
| 23 | + console.error('TT_USER et TT_PASS sont requis (compte de test, jamais un compte réel).'); | |
| 24 | + process.exit(2); | |
| 25 | +} | |
| 26 | +const CHROME = | |
| 27 | + process.env.CHROME ?? execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim(); | |
| 28 | + | |
| 29 | +const KEY_NAME = 'Clé e2e'; | |
| 30 | +let failures = 0; | |
| 31 | +const check = (cond, label) => { | |
| 32 | + if (cond) console.log(` ✓ ${label}`); | |
| 33 | + else { | |
| 34 | + console.error(` ✗ ${label}`); | |
| 35 | + failures++; | |
| 36 | + } | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const browser = await puppeteer.launch({ | |
| 40 | + executablePath: CHROME, | |
| 41 | + headless: 'new', | |
| 42 | + args: ['--no-sandbox', '--disable-dev-shm-usage'], | |
| 43 | +}); | |
| 44 | +const page = await browser.newPage(); | |
| 45 | +await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); | |
| 46 | + | |
| 47 | +// Authenticateur virtuel : clé résidente (condition du login usernameless) et | |
| 48 | +// vérification utilisateur simulée — l'équivalent d'une Yubikey avec PIN. | |
| 49 | +const cdp = await page.createCDPSession(); | |
| 50 | +await cdp.send('WebAuthn.enable'); | |
| 51 | +await cdp.send('WebAuthn.addVirtualAuthenticator', { | |
| 52 | + options: { | |
| 53 | + protocol: 'ctap2', | |
| 54 | + transport: 'internal', | |
| 55 | + hasResidentKey: true, | |
| 56 | + hasUserVerification: true, | |
| 57 | + isUserVerified: true, | |
| 58 | + automaticPresenceSimulation: true, | |
| 59 | + }, | |
| 60 | +}); | |
| 61 | + | |
| 62 | +// domcontentloaded plutôt que networkidle0 : la page de login garde une | |
| 63 | +// requête WebAuthn conditional en attente, l'« idle » n'arrive jamais. | |
| 64 | +const nav = () => page.waitForNavigation({ waitUntil: 'domcontentloaded' }); | |
| 65 | +const until = async (pred, ms) => { | |
| 66 | + const t0 = Date.now(); | |
| 67 | + while (Date.now() - t0 < ms) { | |
| 68 | + if (pred()) return true; | |
| 69 | + await new Promise((r) => setTimeout(r, 200)); | |
| 70 | + } | |
| 71 | + return pred(); | |
| 72 | +}; | |
| 73 | + | |
| 74 | +// ─── connexion par mot de passe ─── | |
| 75 | +await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); | |
| 76 | +await page.type('input[name="username"]', USER); | |
| 77 | +await page.type('input[name="password"]', PASS); | |
| 78 | +await Promise.all([nav(), page.click('button[type="submit"]')]); | |
| 79 | +if (page.url().includes('/login')) { | |
| 80 | + console.error('Connexion échouée — vérifie TT_USER / TT_PASS.'); | |
| 81 | + await browser.close(); | |
| 82 | + process.exit(2); | |
| 83 | +} | |
| 84 | + | |
| 85 | +await page.goto(`${BASE}/parametres/securite`, { waitUntil: 'domcontentloaded' }); | |
| 86 | +if (!(await page.$('form[data-webauthn-add]'))) { | |
| 87 | + console.error('Section clés absente — lancer le serveur avec WEBAUTHN_ORIGIN=http://localhost:8090.'); | |
| 88 | + await browser.close(); | |
| 89 | + process.exit(2); | |
| 90 | +} | |
| 91 | + | |
| 92 | +// Reste d'un passage précédent : on repart d'une liste vide (idempotence). | |
| 93 | +while (await page.$('.category-row')) { | |
| 94 | + await page.click('details summary'); | |
| 95 | + await page.type('details[open] form input[name="mdp"]', PASS); | |
| 96 | + await Promise.all([nav(), page.click('details[open] form button[type="submit"]')]); | |
| 97 | +} | |
| 98 | + | |
| 99 | +// ─── ajout refusé : mauvais mot de passe, rien ne doit naître ─── | |
| 100 | +console.log('Ajout avec mauvais mot de passe :'); | |
| 101 | +await page.type('#cle-nom', KEY_NAME); | |
| 102 | +await page.type('#cle-mdp', 'pas-le-bon'); | |
| 103 | +await Promise.all([nav(), page.click('form[data-webauthn-add] button[type="submit"]')]); | |
| 104 | +check(page.url().includes('err=cle-mdp'), 'refusé (?err=cle-mdp)'); | |
| 105 | +check((await page.$$('.category-row')).length === 0, 'aucune clé créée'); | |
| 106 | + | |
| 107 | +// ─── ajout ─── | |
| 108 | +console.log('Ajout :'); | |
| 109 | +await page.type('#cle-nom', KEY_NAME); | |
| 110 | +await page.type('#cle-mdp', PASS); | |
| 111 | +await Promise.all([nav(), page.click('form[data-webauthn-add] button[type="submit"]')]); | |
| 112 | +check(page.url().includes('ok=cle-ajoutee'), 'clé ajoutée (?ok=cle-ajoutee)'); | |
| 113 | +const listedName = await page | |
| 114 | + .$eval('.category-row form.rename input[name="nom"]', (el) => el.value) | |
| 115 | + .catch(() => null); | |
| 116 | +check(listedName === KEY_NAME, `listée sous son nom (« ${KEY_NAME} »)`); | |
| 117 | + | |
| 118 | +// ─── déconnexion puis connexion par clé ─── | |
| 119 | +console.log('Connexion par clé :'); | |
| 120 | +await page.goto(`${BASE}/parametres`, { waitUntil: 'domcontentloaded' }); | |
| 121 | +await Promise.all([nav(), page.click('form[action="/logout"] button')]); | |
| 122 | +check(page.url().includes('/login'), 'déconnecté'); | |
| 123 | +// L'authenticator virtuel répond tout seul : l'autofill (conditional UI) peut | |
| 124 | +// avoir déjà connecté avant le clic — les deux chemins valident le test. | |
| 125 | +const loggedIn = () => page.url().includes('/temps/nouveau'); | |
| 126 | +await until(loggedIn, 4000); | |
| 127 | +if (!loggedIn()) { | |
| 128 | + await page.waitForSelector('#passkey-login:not([hidden])', { timeout: 5000 }); | |
| 129 | + await page.click('#passkey-login').catch(() => {}); | |
| 130 | + await until(loggedIn, 10000); | |
| 131 | +} | |
| 132 | +check(loggedIn(), 'connecté sans identifiant ni mot de passe'); | |
| 133 | + | |
| 134 | +// ─── renommage (input inline, validation par Entrée) ─── | |
| 135 | +console.log('Renommage :'); | |
| 136 | +await page.goto(`${BASE}/parametres/securite`, { waitUntil: 'domcontentloaded' }); | |
| 137 | +await page.click('.category-row form.rename input[name="nom"]', { clickCount: 3 }); | |
| 138 | +await page.type('.category-row form.rename input[name="nom"]', 'Clé renommée'); | |
| 139 | +await Promise.all([nav(), page.keyboard.press('Enter')]); | |
| 140 | +check(page.url().includes('ok=cle-renommee'), 'renommée (?ok=cle-renommee)'); | |
| 141 | + | |
| 142 | +// ─── suppression (mot de passe exigé) ─── | |
| 143 | +console.log('Suppression :'); | |
| 144 | +await page.click('details summary'); | |
| 145 | +await page.type('details[open] form input[name="mdp"]', PASS); | |
| 146 | +await Promise.all([nav(), page.click('details[open] form button[type="submit"]')]); | |
| 147 | +check(page.url().includes('ok=cle-supprimee'), 'supprimée (?ok=cle-supprimee)'); | |
| 148 | +check((await page.$$('.category-row')).length === 0, 'liste vide'); | |
| 149 | + | |
| 150 | +await browser.close(); | |
| 151 | +if (failures > 0) { | |
| 152 | + console.error(`\n${failures} vérification(s) en échec.`); | |
| 153 | + process.exit(1); | |
| 154 | +} | |
| 155 | +console.log('\nParcours passkey complet : OK.'); | |
| new file mode 100644 | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | +// Parcours complet des clés d'accès (WebAuthn) avec l'authenticateur virtuel | ||
| 2 | +// de Chrome (CDP) : ajout refusé sans le bon mot de passe, ajout, déconnexion, | ||
| 3 | +// connexion par clé (usernameless), renommage, suppression. | ||
| 4 | +// | ||
| 5 | +// Prérequis : un serveur exego local lancé avec la BASE DEV et | ||
| 6 | +// WEBAUTHN_ORIGIN=http://localhost:8090 (sans elle, la section clés n'existe | ||
| 7 | +// pas et le script sort en 2). Le module puppeteer-core est résolu depuis | ||
| 8 | +// a11y/ (npm install là-bas d'abord). | ||
| 9 | +// | ||
| 10 | +// Usage : | ||
| 11 | +// CHROME=/usr/host/bin/google-chrome-stable TT_USER=… TT_PASS=… node e2e/passkey.mjs | ||
| 12 | + | ||
| 13 | +import { execSync } from 'node:child_process'; | ||
| 14 | +import { createRequire } from 'node:module'; | ||
| 15 | + | ||
| 16 | +const require = createRequire(new URL('../a11y/package.json', import.meta.url)); | ||
| 17 | +const puppeteer = require('puppeteer-core'); | ||
| 18 | + | ||
| 19 | +const BASE = process.env.TT_URL ?? 'http://localhost:8090'; | ||
| 20 | +const USER = process.env.TT_USER; | ||
| 21 | +const PASS = process.env.TT_PASS; | ||
| 22 | +if (!USER || !PASS) { | ||
| 23 | + console.error('TT_USER et TT_PASS sont requis (compte de test, jamais un compte réel).'); | ||
| 24 | + process.exit(2); | ||
| 25 | +} | ||
| 26 | +const CHROME = | ||
| 27 | + process.env.CHROME ?? execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim(); | ||
| 28 | + | ||
| 29 | +const KEY_NAME = 'Clé e2e'; | ||
| 30 | +let failures = 0; | ||
| 31 | +const check = (cond, label) => { | ||
| 32 | + if (cond) console.log(` ✓ ${label}`); | ||
| 33 | + else { | ||
| 34 | + console.error(` ✗ ${label}`); | ||
| 35 | + failures++; | ||
| 36 | + } | ||
| 37 | +}; | ||
| 38 | + | ||
| 39 | +const browser = await puppeteer.launch({ | ||
| 40 | + executablePath: CHROME, | ||
| 41 | + headless: 'new', | ||
| 42 | + args: ['--no-sandbox', '--disable-dev-shm-usage'], | ||
| 43 | +}); | ||
| 44 | +const page = await browser.newPage(); | ||
| 45 | +await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); | ||
| 46 | + | ||
| 47 | +// Authenticateur virtuel : clé résidente (condition du login usernameless) et | ||
| 48 | +// vérification utilisateur simulée — l'équivalent d'une Yubikey avec PIN. | ||
| 49 | +const cdp = await page.createCDPSession(); | ||
| 50 | +await cdp.send('WebAuthn.enable'); | ||
| 51 | +await cdp.send('WebAuthn.addVirtualAuthenticator', { | ||
| 52 | + options: { | ||
| 53 | + protocol: 'ctap2', | ||
| 54 | + transport: 'internal', | ||
| 55 | + hasResidentKey: true, | ||
| 56 | + hasUserVerification: true, | ||
| 57 | + isUserVerified: true, | ||
| 58 | + automaticPresenceSimulation: true, | ||
| 59 | + }, | ||
| 60 | +}); | ||
| 61 | + | ||
| 62 | +// domcontentloaded plutôt que networkidle0 : la page de login garde une | ||
| 63 | +// requête WebAuthn conditional en attente, l'« idle » n'arrive jamais. | ||
| 64 | +const nav = () => page.waitForNavigation({ waitUntil: 'domcontentloaded' }); | ||
| 65 | +const until = async (pred, ms) => { | ||
| 66 | + const t0 = Date.now(); | ||
| 67 | + while (Date.now() - t0 < ms) { | ||
| 68 | + if (pred()) return true; | ||
| 69 | + await new Promise((r) => setTimeout(r, 200)); | ||
| 70 | + } | ||
| 71 | + return pred(); | ||
| 72 | +}; | ||
| 73 | + | ||
| 74 | +// ─── connexion par mot de passe ─── | ||
| 75 | +await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); | ||
| 76 | +await page.type('input[name="username"]', USER); | ||
| 77 | +await page.type('input[name="password"]', PASS); | ||
| 78 | +await Promise.all([nav(), page.click('button[type="submit"]')]); | ||
| 79 | +if (page.url().includes('/login')) { | ||
| 80 | + console.error('Connexion échouée — vérifie TT_USER / TT_PASS.'); | ||
| 81 | + await browser.close(); | ||
| 82 | + process.exit(2); | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +await page.goto(`${BASE}/parametres/securite`, { waitUntil: 'domcontentloaded' }); | ||
| 86 | +if (!(await page.$('form[data-webauthn-add]'))) { | ||
| 87 | + console.error('Section clés absente — lancer le serveur avec WEBAUTHN_ORIGIN=http://localhost:8090.'); | ||
| 88 | + await browser.close(); | ||
| 89 | + process.exit(2); | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +// Reste d'un passage précédent : on repart d'une liste vide (idempotence). | ||
| 93 | +while (await page.$('.category-row')) { | ||
| 94 | + await page.click('details summary'); | ||
| 95 | + await page.type('details[open] form input[name="mdp"]', PASS); | ||
| 96 | + await Promise.all([nav(), page.click('details[open] form button[type="submit"]')]); | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +// ─── ajout refusé : mauvais mot de passe, rien ne doit naître ─── | ||
| 100 | +console.log('Ajout avec mauvais mot de passe :'); | ||
| 101 | +await page.type('#cle-nom', KEY_NAME); | ||
| 102 | +await page.type('#cle-mdp', 'pas-le-bon'); | ||
| 103 | +await Promise.all([nav(), page.click('form[data-webauthn-add] button[type="submit"]')]); | ||
| 104 | +check(page.url().includes('err=cle-mdp'), 'refusé (?err=cle-mdp)'); | ||
| 105 | +check((await page.$$('.category-row')).length === 0, 'aucune clé créée'); | ||
| 106 | + | ||
| 107 | +// ─── ajout ─── | ||
| 108 | +console.log('Ajout :'); | ||
| 109 | +await page.type('#cle-nom', KEY_NAME); | ||
| 110 | +await page.type('#cle-mdp', PASS); | ||
| 111 | +await Promise.all([nav(), page.click('form[data-webauthn-add] button[type="submit"]')]); | ||
| 112 | +check(page.url().includes('ok=cle-ajoutee'), 'clé ajoutée (?ok=cle-ajoutee)'); | ||
| 113 | +const listedName = await page | ||
| 114 | + .$eval('.category-row form.rename input[name="nom"]', (el) => el.value) | ||
| 115 | + .catch(() => null); | ||
| 116 | +check(listedName === KEY_NAME, `listée sous son nom (« ${KEY_NAME} »)`); | ||
| 117 | + | ||
| 118 | +// ─── déconnexion puis connexion par clé ─── | ||
| 119 | +console.log('Connexion par clé :'); | ||
| 120 | +await page.goto(`${BASE}/parametres`, { waitUntil: 'domcontentloaded' }); | ||
| 121 | +await Promise.all([nav(), page.click('form[action="/logout"] button')]); | ||
| 122 | +check(page.url().includes('/login'), 'déconnecté'); | ||
| 123 | +// L'authenticator virtuel répond tout seul : l'autofill (conditional UI) peut | ||
| 124 | +// avoir déjà connecté avant le clic — les deux chemins valident le test. | ||
| 125 | +const loggedIn = () => page.url().includes('/temps/nouveau'); | ||
| 126 | +await until(loggedIn, 4000); | ||
| 127 | +if (!loggedIn()) { | ||
| 128 | + await page.waitForSelector('#passkey-login:not([hidden])', { timeout: 5000 }); | ||
| 129 | + await page.click('#passkey-login').catch(() => {}); | ||
| 130 | + await until(loggedIn, 10000); | ||
| 131 | +} | ||
| 132 | +check(loggedIn(), 'connecté sans identifiant ni mot de passe'); | ||
| 133 | + | ||
| 134 | +// ─── renommage (input inline, validation par Entrée) ─── | ||
| 135 | +console.log('Renommage :'); | ||
| 136 | +await page.goto(`${BASE}/parametres/securite`, { waitUntil: 'domcontentloaded' }); | ||
| 137 | +await page.click('.category-row form.rename input[name="nom"]', { clickCount: 3 }); | ||
| 138 | +await page.type('.category-row form.rename input[name="nom"]', 'Clé renommée'); | ||
| 139 | +await Promise.all([nav(), page.keyboard.press('Enter')]); | ||
| 140 | +check(page.url().includes('ok=cle-renommee'), 'renommée (?ok=cle-renommee)'); | ||
| 141 | + | ||
| 142 | +// ─── suppression (mot de passe exigé) ─── | ||
| 143 | +console.log('Suppression :'); | ||
| 144 | +await page.click('details summary'); | ||
| 145 | +await page.type('details[open] form input[name="mdp"]', PASS); | ||
| 146 | +await Promise.all([nav(), page.click('details[open] form button[type="submit"]')]); | ||
| 147 | +check(page.url().includes('ok=cle-supprimee'), 'supprimée (?ok=cle-supprimee)'); | ||
| 148 | +check((await page.$$('.category-row')).length === 0, 'liste vide'); | ||
| 149 | + | ||
| 150 | +await browser.close(); | ||
| 151 | +if (failures > 0) { | ||
| 152 | + console.error(`\n${failures} vérification(s) en échec.`); | ||
| 153 | + process.exit(1); | ||
| 154 | +} | ||
| 155 | +console.log('\nParcours passkey complet : OK.'); | ||
modified
templates/login.html +5 -0 | @@ -90,6 +90,11 @@ | ||
| 90 | 90 | ctrl = new AbortController(); |
| 91 | 91 | login('conditional', ctrl.signal).catch(function (e) { |
| 92 | 92 | if (e && e.name === 'AbortError') return; // remplacé par le flux bouton |
| 93 | + // Autre DOMException/TypeError : l'autofill est opportuniste, un | |
| 94 | + // échec côté authenticator ou réseau s'éteint en silence (rediriger | |
| 95 | + // ici bouclerait : la page rechargée réarmerait le même flux). | |
| 96 | + if (e && e.name) return; | |
| 97 | + // Seul un refus du serveur (JSON {error}) mérite le message. | |
| 93 | 98 | location = '/login?err=cle'; |
| 94 | 99 | }); |
| 95 | 100 | }); |
| @@ -90,6 +90,11 @@ | |||
| 90 | ctrl = new AbortController(); | 90 | ctrl = new AbortController(); |
| 91 | login('conditional', ctrl.signal).catch(function (e) { | 91 | login('conditional', ctrl.signal).catch(function (e) { |
| 92 | if (e && e.name === 'AbortError') return; // remplacé par le flux bouton | 92 | if (e && e.name === 'AbortError') return; // remplacé par le flux bouton |
| 93 | + // Autre DOMException/TypeError : l'autofill est opportuniste, un | ||
| 94 | + // échec côté authenticator ou réseau s'éteint en silence (rediriger | ||
| 95 | + // ici bouclerait : la page rechargée réarmerait le même flux). | ||
| 96 | + if (e && e.name) return; | ||
| 97 | + // Seul un refus du serveur (JSON {error}) mérite le message. | ||
| 93 | location = '/login?err=cle'; | 98 | location = '/login?err=cle'; |
| 94 | }); | 99 | }); |
| 95 | }); | 100 | }); |