// Gestion des tokens d'accès via l'API REST (onglet Réglages) : create → list → revoke, // flag « courant », jamais de hash exposé, garde anti lock-out sur le dernier token. 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 { CreateTokenResponse, MeResponse, TokensListResponse } from '@arboretum/shared'; // Mêmes stubs que les autres tests de routes : pas de vrai claude ni de vrai PTY. 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-tokens-')); const dbPath = join(dir, 'tokens.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('routes de gestion des tokens', () => { it('GET /auth/me expose le tokenId courant', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); expect(res.statusCode).toBe(200); const me = res.json() as MeResponse; expect(typeof me.tokenId).toBe('string'); expect(me.tokenId.length).toBeGreaterThan(0); }); it('liste le token initial et le marque « courant », sans jamais exposer de hash', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as TokensListResponse; expect(body.tokens).toHaveLength(1); expect(body.tokens[0]?.label).toBe('initial'); expect(body.tokens[0]?.current).toBe(true); // aucune fuite de hash / valeur en clair expect(JSON.stringify(body)).not.toMatch(/token_hash|tokenHash/); }); it('crée un token (valeur en clair renvoyée une fois), puis utilisable pour s’authentifier', async () => { const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: 'laptop' }, }); expect(res.statusCode).toBe(201); const created = res.json() as CreateTokenResponse; expect(created.label).toBe('laptop'); expect(created.token).toMatch(/^arb_[0-9a-f]{48}$/); // le nouveau token authentifie réellement const me = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${created.token}` }, }); expect((me.json() as MeResponse).tokenLabel).toBe('laptop'); }); it('rejette un label vide ou trop long (400)', async () => { const empty = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: ' ' } }); expect(empty.statusCode).toBe(400); const tooLong = await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/tokens', headers: auth(), payload: { label: 'x'.repeat(65) } }); expect(tooLong.statusCode).toBe(400); }); it('révoque un token non courant (204), qui disparaît de la liste et n’authentifie plus', async () => { const before = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse; const victim = before.tokens.find((t) => !t.current); expect(victim).toBeDefined(); const del = await bundle.app.inject({ method: 'DELETE', url: `/api/v1/auth/tokens/${victim!.id}`, headers: auth() }); expect(del.statusCode).toBe(200); const after = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse; expect(after.tokens.find((t) => t.id === victim!.id)).toBeUndefined(); }); it('404 sur un id inconnu', async () => { const res = await bundle.app.inject({ method: 'DELETE', url: '/api/v1/auth/tokens/nope-xyz', headers: auth() }); expect(res.statusCode).toBe(404); }); it('409 LAST_TOKEN : refuse de révoquer le dernier token actif', async () => { const list = (await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens', headers: auth() })).json() as TokensListResponse; expect(list.tokens).toHaveLength(1); // seul le token courant subsiste const res = await bundle.app.inject({ method: 'DELETE', url: `/api/v1/auth/tokens/${list.tokens[0]!.id}`, headers: auth() }); expect(res.statusCode).toBe(409); expect(res.json()).toMatchObject({ error: { code: 'LAST_TOKEN' } }); }); it('sans authentification → 401', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/tokens' }); expect(res.statusCode).toBe(401); }); });