import { mkdtempSync, mkdirSync, writeFileSync, 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 { FsListResponse } from '@arboretum/shared'; // Mêmes stubs que app.e2e : pas de vrai claude ni de vrai PTY en CI. 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 root: string; let bundle: AppBundle; let db: Db; let token: string; const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'arboretum-fs-')); // Arbo de test : root/{alpha, Beta, .hidden, a-repo/.git, file.txt} root = join(dir, 'root'); mkdirSync(join(root, 'alpha'), { recursive: true }); mkdirSync(join(root, 'Beta'), { recursive: true }); mkdirSync(join(root, '.hidden'), { recursive: true }); mkdirSync(join(root, 'a-repo', '.git'), { recursive: true }); writeFileSync(join(root, 'file.txt'), 'x'); const dbPath = join(dir, 'fs.db'); db = openDb(dbPath); const config: Config = { port: 7317, bind: '127.0.0.1', dbPath, dataDir: dir, allowedOrigins: [], printToken: false, claudeProjectsDir: join(dir, 'claude', 'projects'), claudeSessionsDir: join(dir, 'claude', 'sessions'), vapidContact: 'mailto:test@localhost', }; bundle = buildApp(config, db, '0.0.0-test'); const t = bundle.auth.ensureBootstrapToken(); if (!t) throw new Error('bootstrap token attendu sur une base vierge'); token = t; }); afterAll(async () => { await bundle.app.close(); db.close(); rmSync(dir, { recursive: true, force: true }); }); describe('GET /api/v1/fs/list', () => { it('liste les sous-dossiers triés, masque les dotfiles et les fichiers', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as FsListResponse; const names = body.entries.map((e) => e.name); expect(names).toEqual(['a-repo', 'alpha', 'Beta']); // tri insensible à la casse, fichiers/dotfiles exclus expect(body.path).toBe(root); expect(body.parent).toBe(dir); expect(typeof body.home).toBe('string'); }); it('showHidden=1 inclut les dossiers cachés', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&showHidden=1`, headers: auth() }); const body = res.json() as FsListResponse; expect(body.entries.map((e) => e.name)).toContain('.hidden'); }); it('markRepos=1 annote le dossier contenant un .git', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&markRepos=1`, headers: auth() }); const body = res.json() as FsListResponse; expect(body.entries.find((e) => e.name === 'a-repo')?.isRepo).toBe(true); expect(body.entries.find((e) => e.name === 'alpha')?.isRepo).toBeUndefined(); }); it('sans markRepos, aucune annotation isRepo', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() }); const body = res.json() as FsListResponse; expect(body.entries.every((e) => e.isRepo === undefined)).toBe(true); }); it('path absent → liste le home (réponse 200 cohérente)', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list', headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as FsListResponse; expect(body.path).toBe(body.home); }); it('chemin relatif → 400 BAD_PATH', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list?path=relatif/x', headers: auth() }); expect(res.statusCode).toBe(400); expect(res.json()).toMatchObject({ error: { code: 'BAD_PATH' } }); }); it('échappement par `..` neutralisé : resolve clampe, pas de fuite (200 sur un dossier réel)', async () => { // `/../..` résout vers un ancêtre réel : la requête réussit mais ne contient jamais de `..`. const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, '..', '..'))}`, headers: auth() }); expect(res.statusCode).toBe(200); expect((res.json() as FsListResponse).path).not.toContain('..'); }); it('chemin inexistant → 404 NOT_FOUND', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'nope-xyz'))}`, headers: auth() }); expect(res.statusCode).toBe(404); expect(res.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }); }); it('cible non-dossier → 400 NOT_A_DIRECTORY', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'file.txt'))}`, headers: auth() }); expect(res.statusCode).toBe(400); expect(res.json()).toMatchObject({ error: { code: 'NOT_A_DIRECTORY' } }); }); it('sans authentification → 401', async () => { const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}` }); expect(res.statusCode).toBe(401); expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }); }); });