Files
arboretum/packages/server/test/audit-data-routes.test.ts
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

165 lines
6.6 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.
// 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 daudit', () => {
it('journalise la création de token et lexpose 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);
});
});