Le code arrive par e-mail, ou il se calcule
Un parcours à double authentification bloque une suite de tests à deux endroits : le code envoyé par e-mail, que le test ne peut pas lire, et celui de l'application d'authentification, qu'il ne peut pas générer. Les deux se règlent, et pas de la même façon.
La requête part avant le clic
Même mécanique que pour une inscription : la requête part avant le clic et répond à l'arrivée du message.
import { expect, test } from '@playwright/test';
const INBOX = 'j3k9x2mq';
// hasOtp vise le code extrait, pas un texte qui le cite.
async function attendreLeCode(sentTo: string) {
const search = new URLSearchParams({ inbox: INBOX, sentTo, hasOtp: 'true', wait: '30000' });
const response = await fetch('https://api.facteur.eu/v1/messages?' + search, {
headers: { authorization: 'Bearer ' + process.env.FACTEUR_API_KEY },
});
const { messages } = await response.json();
if (!messages[0]) throw new Error('Aucun code pour ' + sentTo);
return messages[0].otp;
}
test('la connexion demande le code reçu par e-mail', async ({ page }) => {
const tag = 'login-' + Date.now();
const arrive = attendreLeCode(tag);
await page.goto('/connexion');
await page.getByLabel('Email').fill(tag + '@' + INBOX + '.inbox.facteur.eu');
await page.getByRole('button', { name: 'Continuer' }).click();
// Le code, extrait à la réception.
await page.getByLabel('Code').fill(await arrive);
await expect(page.getByText('Bienvenue')).toBeVisible();
});
- Un critère qui vise le code, pas le texte
- Le critère hasOtp demande « le message qui porte un code », ce qui évite de retomber sur un e-mail qui cite un ancien code dans son historique.
- Le code vit dans son propre champ
- Extrait à la réception. Votre test compare une valeur, il ne cherche pas un motif dans du HTML.
Vingt lignes de Node, aucune dépendance
Celui-là, personne ne peut vous l'envoyer : il se calcule à partir du secret que votre écran d'activation affiche. Facteur n'y joue aucun rôle, et le téléphone reste dans le tiroir.
import { createHmac } from 'node:crypto';
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** Le secret que votre écran d'activation affiche, en base32. */
function decode(secret: string): Buffer {
let bits = 0;
let value = 0;
const out: number[] = [];
for (const c of secret.replace(/=/g, '').toUpperCase()) {
value = (value << 5) | BASE32.indexOf(c);
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
/** RFC 6238 : SHA-1, six chiffres, pas de trente secondes. */
export function totp(secret: string, at = Date.now()): string {
const counter = Math.floor(at / 1000 / 30);
const message = Buffer.alloc(8);
message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
message.writeUInt32BE(counter % 2 ** 32, 4);
const digest = createHmac('sha1', decode(secret)).update(message).digest();
const offset = digest[digest.length - 1] & 0x0f;
const binary =
((digest[offset] & 0x7f) << 24) |
(digest[offset + 1] << 16) |
(digest[offset + 2] << 8) |
digest[offset + 3];
return String(binary % 1_000_000).padStart(6, '0');
}
Ce que ça change pour une suite de tests
Le parcours complet est testable
Activation de la double authentification, connexion, code de secours : rien ne dépend plus d'un appareil physique ni d'une boîte aux lettres personnelle.
Le test reste lisible
Deux appels et une assertion. Le code n'est pas extrait par vous, et l'attente n'est pas une boucle que vous maintenez.
Les exécutions parallèles ne se marchent pas dessus
Chaque test reçoit à son adresse, donc le code lu est celui de son propre parcours.
Écrit, pas encore publié
playwright-facteur sait attendre un message qui porte un code. Il n'est pas encore publié sur npm. Le test ci-dessus n'attend rien de lui : il appelle l'API directement, et continuera de marcher tel quel.
Débloquez vos tests de double authentification
Une inbox, une clé en lecture seule, et le parcours entier redevient testable. L'offre gratuite ne demande pas de carte bancaire.