Files
arboretum/packages/server/test/auth.test.ts
Johan LEROY e9404cb567 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
2026-06-11 22:29:58 +02:00

209 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
});
});