import { createHmac } from 'node:crypto'; import { beforeEach, describe, expect, it } from 'vitest'; import { AuthService, LoginRateLimiter, type AuthContext } from '../src/auth/service.js'; import { getSetting, openDb, type Db } from '../src/db/index.js'; describe('AuthService', () => { let db: Db; let auth: AuthService; beforeEach(() => { db = openDb(':memory:'); auth = new AuthService(db); }); describe('bootstrap token', () => { it('créé une seule fois : le second appel renvoie null', () => { const token = auth.ensureBootstrapToken(); expect(token).toMatch(/^arb_[0-9a-f]{48}$/); expect(auth.ensureBootstrapToken()).toBeNull(); }); it('recréé si tous les tokens sont révoqués', () => { const first = auth.ensureBootstrapToken(); expect(first).not.toBeNull(); db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString()); const second = auth.ensureBootstrapToken(); expect(second).not.toBeNull(); expect(second).not.toBe(first); }); }); describe('verifyRawToken', () => { it('token valide → contexte avec label', () => { const raw = auth.ensureBootstrapToken(); const ctx = auth.verifyRawToken(raw as string); expect(ctx).not.toBeNull(); expect(ctx?.label).toBe('initial'); expect(typeof ctx?.tokenId).toBe('string'); }); it('met à jour last_used_at au passage', () => { const raw = auth.ensureBootstrapToken() as string; const ctx = auth.verifyRawToken(raw) as AuthContext; const row = db.prepare('SELECT last_used_at FROM auth_tokens WHERE id = ?').get(ctx.tokenId) as { last_used_at: string | null }; expect(row.last_used_at).not.toBeNull(); }); it('token inconnu, trop court ou trop long → null', () => { auth.ensureBootstrapToken(); expect(auth.verifyRawToken('arb_' + 'f'.repeat(48))).toBeNull(); // bon format, mauvaise valeur expect(auth.verifyRawToken('short')).toBeNull(); // < 8 chars expect(auth.verifyRawToken('a'.repeat(129))).toBeNull(); // > 128 chars expect(auth.verifyRawToken('')).toBeNull(); }); it('token révoqué → null', () => { const raw = auth.ensureBootstrapToken() as string; expect(auth.verifyRawToken(raw)).not.toBeNull(); db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString()); expect(auth.verifyRawToken(raw)).toBeNull(); }); it('createToken : deux tokens coexistent avec leurs labels', () => { auth.ensureBootstrapToken(); const raw2 = auth.createToken('laptop'); expect(auth.verifyRawToken(raw2)?.label).toBe('laptop'); }); }); describe('cookies', () => { let ctx: AuthContext; beforeEach(() => { const raw = auth.ensureBootstrapToken() as string; ctx = auth.verifyRawToken(raw) as AuthContext; }); // Signe un payload arbitraire avec le vrai secret du serveur (lu en base), // pour forger des cookies à la structure valide mais au contenu choisi. function forge(payload: string): string { const secretHex = getSetting(db, 'server_secret') as string; const sig = createHmac('sha256', Buffer.from(secretHex, 'hex')).update(payload).digest('hex'); return `${payload}.${sig}`; } it('cookie émis puis vérifié → même tokenId', () => { const cookie = auth.issueCookie(ctx); const back = auth.verifyCookie(cookie); expect(back).toEqual({ tokenId: ctx.tokenId, label: 'initial' }); }); it('le secret persiste : un second AuthService sur la même base vérifie le cookie', () => { const cookie = auth.issueCookie(ctx); const auth2 = new AuthService(db); expect(auth2.verifyCookie(cookie)?.tokenId).toBe(ctx.tokenId); }); it('cookie altéré (signature ou payload) → null', () => { const cookie = auth.issueCookie(ctx); // un caractère de la signature inversé const last = cookie.slice(-1); const tamperedSig = cookie.slice(0, -1) + (last === 'a' ? 'b' : 'a'); expect(auth.verifyCookie(tamperedSig)).toBeNull(); // expiration repoussée dans le payload sans re-signer const lastDot = cookie.lastIndexOf('.'); const [tokenId, expires] = cookie.slice(0, lastDot).split('.') as [string, string]; const inflated = `${tokenId}.${Number(expires) + 1_000_000}.${cookie.slice(lastDot + 1)}`; expect(auth.verifyCookie(inflated)).toBeNull(); }); it('cookie malformé ou absent → null', () => { expect(auth.verifyCookie(undefined)).toBeNull(); expect(auth.verifyCookie('')).toBeNull(); expect(auth.verifyCookie('garbage')).toBeNull(); expect(auth.verifyCookie('.sig')).toBeNull(); expect(auth.verifyCookie('a.b.c.d')).toBeNull(); }); it('cookie expiré forgé avec le vrai secret → null (la forge non expirée passe, preuve que seule l’expiration rejette)', () => { const fresh = forge(`${ctx.tokenId}.${Date.now() + 10_000}`); expect(auth.verifyCookie(fresh)?.tokenId).toBe(ctx.tokenId); const expired = forge(`${ctx.tokenId}.${Date.now() - 1}`); expect(auth.verifyCookie(expired)).toBeNull(); }); it('cookie forgé avec tokenId vide → null', () => { expect(auth.verifyCookie(forge(`.${Date.now() + 10_000}`))).toBeNull(); }); it('cookie forgé avec expiration non numérique → null', () => { expect(auth.verifyCookie(forge(`${ctx.tokenId}.notanumber`))).toBeNull(); }); it('cookie valide mais token révoqué entre-temps → null', () => { const cookie = auth.issueCookie(ctx); db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), ctx.tokenId); expect(auth.verifyCookie(cookie)).toBeNull(); }); it('cookie signé pour un tokenId inexistant → null', () => { const cookie = forge(`00000000-0000-0000-0000-000000000000.${Date.now() + 10_000}`); expect(auth.verifyCookie(cookie)).toBeNull(); }); }); }); describe('LoginRateLimiter', () => { it('5 tentatives par minute glissante, puis délai renvoyé', () => { let t = 0; const lim = new LoginRateLimiter(5, () => t); for (let i = 0; i < 5; i++) expect(lim.check()).toBeNull(); expect(lim.check()).toBe(60_000); // toutes les tentatives à t=0 t = 59_999; expect(lim.check()).toBe(1); // il reste 1 ms avant que la fenêtre glisse t = 60_000; expect(lim.check()).toBeNull(); // fenêtre vidée }); it('la fenêtre glisse tentative par tentative', () => { let t = 0; const lim = new LoginRateLimiter(2, () => t); expect(lim.check()).toBeNull(); // t=0 t = 30_000; expect(lim.check()).toBeNull(); // t=30s t = 40_000; expect(lim.check()).toBe(20_000); // bloqué par la tentative t=0 t = 60_000; expect(lim.check()).toBeNull(); // t=0 expirée, t=30s reste }); it('backoff exponentiel à partir de 3 échecs consécutifs', () => { let t = 0; const lim = new LoginRateLimiter(1000, () => t); // plafond haut pour isoler le backoff lim.recordFailure(); lim.recordFailure(); expect(lim.check()).toBeNull(); // 2 échecs : pas encore de lock lim.recordFailure(); // 3e échec → 30 s expect(lim.check()).toBe(30_000); t = 29_999; expect(lim.check()).toBe(1); t = 30_000; expect(lim.check()).toBeNull(); // lock levé lim.recordFailure(); // 4e échec → 60 s expect(lim.check()).toBe(60_000); t = 90_000; lim.recordFailure(); // 5e échec → 120 s expect(lim.check()).toBe(120_000); }); it('le backoff est plafonné à 30 minutes', () => { let t = 0; const lim = new LoginRateLimiter(1000, () => t); for (let i = 0; i < 20; i++) lim.recordFailure(); expect(lim.check()).toBe(30 * 60_000); }); it('reset complet au succès', () => { let t = 0; const lim = new LoginRateLimiter(1000, () => t); for (let i = 0; i < 5; i++) lim.recordFailure(); expect(lim.check()).not.toBeNull(); // verrouillé lim.recordSuccess(); expect(lim.check()).toBeNull(); // déverrouillé immédiatement lim.recordFailure(); lim.recordFailure(); expect(lim.check()).toBeNull(); // le compteur d'échecs consécutifs est bien reparti de zéro }); });