P1 complete: web front, test suite, CI — acceptance ALL GREEN

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
This commit is contained in:
2026-06-11 22:29:58 +02:00
parent 4768b606e4
commit e9404cb567
42 changed files with 4624 additions and 32 deletions

View File

@@ -0,0 +1,208 @@
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 lexpiration 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
});
});