judu/ex-egopublic Fork
⑂ 6cfca47
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
Julien Durillon committed 2026-08-26T14:18:46+02:00 Browse files
6cfca47 parent: 4f5b01c
modified a11y/audit.mjs +5 -2
@@ -1,6 +1,6 @@
11 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
22 //
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
44 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
55 // les violations moindres sont listées en avertissement.
66 //
@@ -82,12 +82,15 @@ const pages = [
8282 ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` },
8383 { name: 'settings', url: '/parametres' },
8484 { 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' },
8588 { name: 'team-sheet', url: '/temps/nouveau#equipes' },
8689 { name: 'todos', url: '/taches' },
8790 { name: 'todo-new', url: '/taches/nouvelle' },
8891 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
8992 ].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.');
9194
9295 let blocking = 0;
9396 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 échoue3+// 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 @@
9090 ctrl = new AbortController();
9191 login('conditional', ctrl.signal).catch(function (e) {
9292 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.
9398 location = '/login?err=cle';
9499 });
95100 });
@@ -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 bouton92 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 });