Files
arboretum/packages/server/test/jsonl-discovery.test.ts
Johan LEROY c88ae9b2ae feat: clean install (Gitea en dur, scan off) + durcissement sécurité entreprise
Gitea
- lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible
- retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests)

Découverte des dépôts
- aucune racine de scan par défaut → pas de scan au premier démarrage (clean install)
- corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo
  (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille)

Sécurité « enterprise-deployable »
- en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré
- permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations
- chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox
- journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages
- SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI

CI
- pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » :
  corrige l'embarquement de @arboretum/shared dans le tarball

Purge des données personnelles du dépôt public
- suppression de spikes/s4-discovery/result.json, reformulation du VERDICT
- chemin de test générique, anonymisation des 5 fixtures de dialogues

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:44:25 +02:00

94 lines
3.9 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { munge, parseLines, scanProjects } from '../src/core/jsonl-discovery.js';
function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object[]): void {
const dir = join(projectsDir, munge(cwd));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}.jsonl`), `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`);
}
describe('munge', () => {
it('reproduit le nom de dossier ~/.claude/projects', () => {
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
expect(munge('/home/user/projects/demo')).toBe('-home-user-projects-demo');
});
});
describe('parseLines (tolérance)', () => {
it('ignore les lignes vides, coupées ou malformées sans throw', () => {
const text = '{"a":1}\n\n{not json}\n{"b":2}\n{"c":';
expect(parseLines(text)).toEqual([{ a: 1 }, { b: 2 }]);
});
it('shift/pop les lignes partielles en bord de chunk', () => {
const text = 'partial}\n{"ok":1}\n{also partial';
expect(parseLines(text, true, true)).toEqual([{ ok: 1 }]);
});
});
describe('scanProjects', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arb-jsonl-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('dossier absent → tableau vide', async () => {
expect(await scanProjects(join(dir, 'nope'))).toEqual([]);
});
it('extrait claudeSessionId + cwd + branche + version', async () => {
writeJsonl(dir, '/home/u/proj', 'sid-1', [
{ type: 'user', sessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170', message: { content: 'hello world' } },
]);
const res = await scanProjects(dir);
expect(res).toHaveLength(1);
expect(res[0]).toMatchObject({ claudeSessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170' });
});
it('écarte un fichier sans cwd (queue-operation only)', async () => {
writeJsonl(dir, '/x', 'sid-q', [{ type: 'queue-operation', sessionId: 'sid-q' }]);
expect(await scanProjects(dir)).toEqual([]);
});
it('survit à une ligne malformée et reste exploitable', async () => {
const d = join(dir, munge('/home/u/p2'));
mkdirSync(d, { recursive: true });
writeFileSync(
join(d, 'sid-2.jsonl'),
`{bad json\n${JSON.stringify({ type: 'user', sessionId: 'sid-2', cwd: '/home/u/p2', message: { content: 'ok' } })}\n`,
);
const res = await scanProjects(dir);
expect(res).toHaveLength(1);
expect(res[0].claudeSessionId).toBe('sid-2');
});
describe('priorité de titre (ai-title > summary > last-prompt > 1er user)', () => {
it('ai-title gagne sur tout', async () => {
writeJsonl(dir, '/t/1', 's', [
{ type: 'user', sessionId: 's', cwd: '/t/1', message: { content: 'premier prompt' } },
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
{ type: 'ai-title', sessionId: 's', aiTitle: 'Titre IA' },
]);
expect((await scanProjects(dir))[0].title).toBe('Titre IA');
});
it('summary gagne sur last-prompt et user', async () => {
writeJsonl(dir, '/t/2', 's', [
{ type: 'user', sessionId: 's', cwd: '/t/2', message: { content: 'premier prompt' } },
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
]);
expect((await scanProjects(dir))[0].title).toBe('un résumé');
});
it('repli ultime sur le premier prompt user', async () => {
writeJsonl(dir, '/t/3', 's', [{ type: 'user', sessionId: 's', cwd: '/t/3', message: { content: 'premier prompt' } }]);
expect((await scanProjects(dir))[0].title).toBe('premier prompt');
});
});
});