// Lot 5 — sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression), // garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { buildApp, type AppBundle } from '../src/app.js'; import { openDb, type Db } from '../src/db/index.js'; import type { Config } from '../src/config.js'; import type { AuditLogsResponse, DataExportResponse, DeleteMyDataResponse } from '@arboretum/shared'; vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { class FakePty { pid = 424242; write = vi.fn(); resize = vi.fn(); pause = vi.fn(); resume = vi.fn(); kill = vi.fn(); onData(): { dispose: () => void } { return { dispose: () => {} }; } onExit(): { dispose: () => void } { return { dispose: () => {} }; } } return { default: { spawn: (): FakePty => new FakePty() } }; }); process.env.ARBORETUM_LOG = 'silent'; let dir: string; let bundle: AppBundle; let db: Db; let token: string; const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'arboretum-audit-')); const dbPath = join(dir, 'audit.db'); db = openDb(dbPath); const config: Config = { port: 9998, bind: '127.0.0.1', dbPath, dataDir: dir, allowedOrigins: ['https://host.tailnet.ts.net'], printToken: false, claudeProjectsDir: join(dir, 'claude', 'projects'), claudeSessionsDir: join(dir, 'claude', 'sessions'), vapidContact: 'mailto:test@localhost', autoDiscover: false, }; bundle = buildApp(config, db, '1.2.3-test'); const t = bundle.auth.ensureBootstrapToken(); if (!t) throw new Error('bootstrap token attendu'); token = t; }); afterAll(async () => { await bundle.app.close(); db.close(); rmSync(dir, { recursive: true, force: true }); }); describe('En-têtes de sécurité', () => { it('pose les en-têtes durs + no-store sur /api, sans header Server', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); expect(res.statusCode).toBe(200); expect(res.headers['x-content-type-options']).toBe('nosniff'); expect(res.headers['x-frame-options']).toBe('DENY'); expect(res.headers['referrer-policy']).toBe('no-referrer'); expect(res.headers['content-security-policy']).toContain("default-src 'self'"); expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'"); expect(String(res.headers['cache-control'])).toContain('no-store'); expect(res.headers['server']).toBeUndefined(); }); it('pose HSTS uniquement derrière HTTPS (x-forwarded-proto)', async () => { const http = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); expect(http.headers['strict-transport-security']).toBeUndefined(); const https = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { ...auth(), 'x-forwarded-proto': 'https' }, }); expect(String(https.headers['strict-transport-security'])).toContain('max-age='); }); it('rejette une mutation avec corps non-JSON (415)', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: { ...auth(), 'content-type': 'text/plain' }, payload: 'label=pwned', }); expect(res.statusCode).toBe(415); }); }); describe('Journal d’audit', () => { it('journalise la création de token et l’expose via GET /audit-logs', async () => { const create = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: 'ci-extra' }, }); expect(create.statusCode).toBe(201); const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs', headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as AuditLogsResponse; const actions = body.entries.map((e) => e.action); expect(actions).toContain('token.create'); expect(actions).toContain('secret.generate'); // généré au bootstrap (system) // aucune VALEUR secrète en clair : pas de chaîne hex de 64 caractères (server_secret / clé). // (les ids sont des UUID avec tirets → non concernés ; 'server_secret' est un nom de ressource.) expect(res.body).not.toMatch(/[a-f0-9]{64}/i); }); it('journalise les échecs de login (actor anonymous)', async () => { await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_invalid_xxx' } }); const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs?limit=200', headers: auth() }); const body = res.json() as AuditLogsResponse; expect(body.entries.some((e) => e.action === 'login.failure' && e.actor === 'anonymous')).toBe(true); }); }); describe('RGPD', () => { it('exporte les données du token authentifié', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/data/export', headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as DataExportResponse; expect(Array.isArray(body.tokens)).toBe(true); expect(body.tokens.some((t) => t.current)).toBe(true); expect(Array.isArray(body.pushSubscriptions)).toBe(true); expect(Array.isArray(body.sessions)).toBe(true); expect(body.settings).toHaveProperty('scanRoots'); }); it('supprime en deux temps (pending → confirm → done) et révoque le token', async () => { const pending = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: {} }); const p = pending.json() as DeleteMyDataResponse; expect(p.status).toBe('pending'); expect(p.confirm).toBeTruthy(); // un 2e token existe (créé plus haut) → révoquer le token courant est autorisé. const done = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: { confirm: p.confirm }, }); const d = done.json() as DeleteMyDataResponse; expect(d.status).toBe('done'); expect(d.summary.tokenRevoked).toBe(true); // le token courant est désormais révoqué → 401. const after = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); expect(after.statusCode).toBe(401); }); });