Fan-out integration + fixes found by the test/acceptance pass: - FIX ring-buffer: chunks >= capacity skipped bytes now count into the monotonic offset (invariant: stream byte k lives at k % capacity) — window order was corrupted on unaligned big chunks - FIX auth: non-numeric cookie expiry no longer bypasses expiration - FIX protocol: safe-integer validation on ack.bytes / hello.protocol - FIX @fastify/websocket v11: websocket route must be registered in an encapsulated context after plugin load (handler got REST signature) - FIX flow-control deadlock found by e2e acceptance: client only ACKs on data receipt, so pausing with an unACKed residue in (LOW, ACK_EVERY] stalled both sides at 0.9 MB. ACK_EVERY now 64 KiB (<= LOW invariant, tested) + trailing debounced ACK in the web client - Web: Vue 3 + Vite + Pinia + Tailwind 4 + vue-i18n (EN/FR) + xterm 6 (fit + webgl fallback), multiplexed ws-client with reconnect/backoff and resync epochs - Tests: 100 vitest (protocol fuzz, ring edges, auth, pty-manager flow control with mocked pty, REST e2e) ; CI Node 22/24 + pack-smoke - scripts/acceptance-p1.mjs: real daemon + real WS client — boot, login, attach, stdin, 10 MB flood w/ ACK (13.7 MB/1.9s, RSS bounded), brutal disconnect + replay resync, kill broadcast, SIGTERM drain Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
209 lines
8.1 KiB
TypeScript
209 lines
8.1 KiB
TypeScript
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
|
||
});
|
||
});
|