Files
arboretum/packages/server/test/settings-routes.test.ts
Johan LEROY c8dd539571 feat(p10): archivage automatique des sessions terminées (rétention configurable)
- DB: migration #10 (sessions.archived_at + index), helpers archive/unarchive/archiveExpired (soft-archive, jamais de DELETE)
- core/retention-settings.ts: session_retention_days (défaut 30, 0=jamais, 1-3650) + session_purge_days (off)
- core/session-archive.ts: scheduler start/stop/sweep (calqué DiscoveryService), câblé dans runDaemon
- pty-manager.list({includeArchived}) + SessionSummary.archived + emitHistoricalUpdate
- routes: GET /sessions?includeArchived, POST/DELETE /sessions/:id/archive, POST /sessions/archive-now
- protocole additif: message WS session_archived (relayé par la gateway, abonnés 'sessions')
- settings: retentionDays/purgeDays exposés et validés dans PATCH /settings
- web: store sessions (showArchived + archive/unarchive), SessionsListView (toggle/badge/actions), SettingsView (slider rétention), i18n EN+FR
- tests: retention-settings + session-archive (vitest) + settings-routes étendu ; acceptance-p10.mjs (sweep, event WS, resume d'une session archivée → 201, rétention=0)
2026-06-27 13:31:28 +02:00

214 lines
9.9 KiB
TypeScript
Raw 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.
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
// l'allow-list (découverte des dépôts) et ignore toute clé hors allow-list.
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 { getSetting, openDb, type Db } from '../src/db/index.js';
import type { Config } from '../src/config.js';
import type { SettingsResponse } 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-settings-'));
const dbPath = join(dir, 'settings.db');
db = openDb(dbPath);
const config: Config = {
port: 9999,
bind: '127.0.0.1',
dbPath,
dataDir: dir,
allowedOrigins: ['https://host.tailnet.ts.net'],
printToken: false,
claudeHome: join(dir, 'claude'),
claudeHomeFromFlag: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
};
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('GET /api/v1/settings', () => {
it('renvoie la config serveur non sensible et aucune racine de scan par défaut', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.server.version).toBe('1.2.3-test');
expect(body.server.port).toBe(9999);
expect(body.server.bind).toBe('127.0.0.1');
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
expect(body.settings.scanRoots).toEqual([]);
expect(body.settings.scanIntervalMin).toBe(5);
// Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude).
expect(body.settings.claudeBinPath).toBeNull();
expect(body.settings.claudeHome).toBeNull();
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
// rétention des sessions (P10) : défaut 30 j d'archivage, purge désactivée (0).
expect(body.settings.retentionDays).toBe(30);
expect(body.settings.purgeDays).toBe(0);
});
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
const raw = res.body;
const secret = getSetting(db, 'server_secret');
const vapidPrivate = getSetting(db, 'vapid_private');
expect(secret).toBeTruthy();
expect(raw).not.toContain(secret as string);
expect(raw).not.toContain(vapidPrivate as string);
expect(raw).not.toMatch(/server_secret|vapid_private|privateKey/);
});
});
describe('PATCH /api/v1/settings — sécurité', () => {
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
const before = getSetting(db, 'server_secret');
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
expect(getSetting(db, 'server_secret')).toBe(before); // inchangé
expect(getSetting(db, 'vapid_private')).not.toBe('pwned');
});
it('sans authentification → 401', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { scanRoots: ['/home/u/work'] } });
expect(res.statusCode).toBe(401);
});
});
describe('PATCH /api/v1/settings — découverte des dépôts', () => {
it('enregistre des scanRoots et un intervalle valides et les renvoie', async () => {
const res = await bundle.app.inject({
method: 'PATCH',
url: '/api/v1/settings',
headers: auth(),
payload: { scanRoots: ['/home/u/work', '/srv/code'], scanIntervalMin: 10 },
});
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
expect(body.settings.scanIntervalMin).toBe(10);
// persisté
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
expect((get.json() as SettingsResponse).settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
});
it('rejette des racines non absolues, "/", avec ".." ou en surnombre (400)', async () => {
const bads: unknown[] = [['relative/path'], ['/'], ['/a/../b'], Array.from({ length: 17 }, (_, i) => `/r${i}`)];
for (const scanRoots of bads) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanRoots } });
expect(res.statusCode).toBe(400);
}
});
it('rejette un intervalle hors borne (400)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: 5000 } });
expect(res.statusCode).toBe(400);
const neg = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: -1 } });
expect(neg.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/settings — Claude CLI', () => {
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
const realBin = process.execPath;
it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBe(realBin);
expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true });
});
it('réinitialise le chemin avec "" (retour à lauto-détection via PATH)', async () => {
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } });
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBeNull();
expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude)
});
it('rejette un chemin non absolu ou non exécutable (400)', async () => {
for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } });
expect(res.statusCode).toBe(400);
}
});
it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => {
const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } });
expect(set.statusCode).toBe(200);
expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir);
const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } });
expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull();
});
it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => {
for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } });
expect(res.statusCode).toBe(400);
}
});
});
describe('PATCH /api/v1/settings — rétention des sessions (P10)', () => {
it('enregistre une rétention valide et lexpose', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 7 } });
expect(res.statusCode).toBe(200);
expect((res.json() as SettingsResponse).settings.retentionDays).toBe(7);
});
it('accepte 0 (= jamais archiver)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 0 } });
expect(res.statusCode).toBe(200);
expect((res.json() as SettingsResponse).settings.retentionDays).toBe(0);
});
it('rejette une rétention hors borne ou non-entière (400)', async () => {
for (const retentionDays of [-1, 3651, 1.5]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays } });
expect(res.statusCode).toBe(400);
}
});
});