Réglages : préférences (langue, notifications push + test), gestion complète des tokens d'accès (liste/création/révocation, garde anti lock-out sur le dernier token), URL Gitea configurable, infos serveur en lecture seule (port/bind/origines/VAPID + flags CLI). Aide : documentation bilingue EN/FR de toutes les fonctionnalités, avec recherche. Icône Gitea (lien externe) dans la nav (sidebar + MoreSheet mobile). Backend : routes /api/v1/auth/tokens (GET/POST/DELETE) + tokenId dans /me ; routes/settings.ts (GET/PATCH, allow-list stricte gitea_url, aucun secret exposé, URL validée http/https anti-XSS) ; AuthService.listTokens/revokeToken (transaction). Front : NavItem gère les liens externes, nav primaire/secondaire, store settings, vues SettingsView/HelpView. 236 tests verts (+15 nouveaux : auth-tokens, settings-routes).
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
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 { munge } from '../src/core/jsonl-discovery.js';
|
||
import { readProcStart } from '../src/core/session-registry.js';
|
||
import type { Config } from '../src/config.js';
|
||
|
||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||
|
||
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
||
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(_cb: (d: string) => void): { dispose: () => void } {
|
||
return { dispose: () => {} };
|
||
}
|
||
onExit(_cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
|
||
return { dispose: () => {} };
|
||
}
|
||
}
|
||
return { default: { spawn: (): FakePty => new FakePty() } };
|
||
});
|
||
|
||
process.env.ARBORETUM_LOG = 'silent';
|
||
|
||
const PORT = 7317;
|
||
const ALLOWED_ORIGIN = 'https://arboretum.test-tailnet.example';
|
||
|
||
interface TestApp {
|
||
bundle: AppBundle;
|
||
db: Db;
|
||
token: string;
|
||
}
|
||
|
||
let dir: string;
|
||
const apps: TestApp[] = [];
|
||
|
||
function makeApp(name: string): TestApp {
|
||
const dbPath = join(dir, `${name}.db`);
|
||
const db = openDb(dbPath);
|
||
const config: Config = {
|
||
port: PORT,
|
||
bind: '127.0.0.1',
|
||
dbPath,
|
||
dataDir: dir,
|
||
allowedOrigins: [ALLOWED_ORIGIN],
|
||
printToken: false,
|
||
// ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
|
||
claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
|
||
claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
|
||
};
|
||
const bundle = buildApp(config, db, '0.0.0-test');
|
||
const token = bundle.auth.ensureBootstrapToken();
|
||
if (!token) throw new Error('bootstrap token attendu sur une base vierge');
|
||
const app: TestApp = { bundle, db, token };
|
||
apps.push(app);
|
||
return app;
|
||
}
|
||
|
||
beforeAll(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'arboretum-e2e-'));
|
||
});
|
||
|
||
afterAll(async () => {
|
||
for (const a of apps) await a.bundle.app.close();
|
||
for (const a of apps) a.db.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('app e2e — auth, origin et sessions', () => {
|
||
let t: TestApp;
|
||
let cookieValue: string;
|
||
|
||
// Le rate limiter du login est GLOBAL à l'app : un seul login réussi ici,
|
||
// toutes les variantes d'échec vivent dans le describe rate limit (app dédiée).
|
||
beforeAll(async () => {
|
||
t = makeApp('main');
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()).toEqual({ ok: true, label: 'initial' });
|
||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cookie).toBeDefined();
|
||
expect(cookie?.httpOnly).toBe(true);
|
||
expect(cookie?.sameSite).toBe('Strict');
|
||
// Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
|
||
expect(cookie?.secure).toBeFalsy();
|
||
cookieValue = cookie!.value;
|
||
});
|
||
|
||
it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/auth/login',
|
||
headers: { 'x-forwarded-proto': 'https' },
|
||
payload: { token: t.token },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cookie?.secure).toBe(true);
|
||
expect(cookie?.httpOnly).toBe(true);
|
||
expect(cookie?.sameSite).toBe('Strict');
|
||
});
|
||
|
||
it('routes API sans auth → 401', async () => {
|
||
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
||
const res = await t.bundle.app.inject({ method: 'GET', url });
|
||
expect(res.statusCode).toBe(401);
|
||
expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
|
||
}
|
||
const post = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions', payload: { cwd: tmpdir() } });
|
||
expect(post.statusCode).toBe(401);
|
||
});
|
||
|
||
it('cookie émis au login → /me identifié', async () => {
|
||
const me = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/auth/me',
|
||
cookies: { arb_session: cookieValue },
|
||
});
|
||
expect(me.statusCode).toBe(200);
|
||
expect(me.json()).toMatchObject({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
|
||
expect(typeof (me.json() as { tokenId: string }).tokenId).toBe('string');
|
||
});
|
||
|
||
it('cookie altéré → 401', async () => {
|
||
const tampered = cookieValue.slice(0, -1) + (cookieValue.endsWith('a') ? 'b' : 'a');
|
||
const me = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', cookies: { arb_session: tampered } });
|
||
expect(me.statusCode).toBe(401);
|
||
});
|
||
|
||
it('Bearer token accepté sur les routes API', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()).toEqual({ sessions: [] });
|
||
});
|
||
|
||
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}`, origin: 'https://evil.example' },
|
||
});
|
||
expect(res.statusCode).toBe(403);
|
||
expect(res.json()).toMatchObject({ error: { code: 'BAD_ORIGIN' } });
|
||
|
||
const login = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/auth/login',
|
||
headers: { origin: 'https://evil.example' },
|
||
payload: { token: t.token },
|
||
});
|
||
expect(login.statusCode).toBe(403);
|
||
});
|
||
|
||
it('Origins autorisées : loopback du port configuré et --allow-origin', async () => {
|
||
for (const origin of [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`, ALLOWED_ORIGIN]) {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}`, origin },
|
||
});
|
||
expect(res.statusCode, `origin ${origin}`).toBe(200);
|
||
}
|
||
});
|
||
|
||
it('POST /sessions : cwd relatif → 400', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: 'relative/path' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
expect(res.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } });
|
||
});
|
||
|
||
it('POST /sessions : cwd absent ou non-string → 400', async () => {
|
||
const res1 = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: {},
|
||
});
|
||
expect(res1.statusCode).toBe(400);
|
||
const res2 = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: 42 },
|
||
});
|
||
expect(res2.statusCode).toBe(400);
|
||
});
|
||
|
||
it('POST /sessions : cwd inexistant → 400 SPAWN_FAILED', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: '/definitely/not/a/dir' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
expect(res.json()).toMatchObject({ error: { code: 'SPAWN_FAILED' } });
|
||
});
|
||
|
||
it('POST /sessions : command inconnue → 400', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: tmpdir(), command: 'zsh' },
|
||
});
|
||
expect(res.statusCode).toBe(400);
|
||
});
|
||
|
||
it('POST /sessions valide (bash, pty mocké) → 201 puis listée', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'POST',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
payload: { cwd: tmpdir(), command: 'bash' },
|
||
});
|
||
expect(res.statusCode).toBe(201);
|
||
const { session } = res.json() as { session: { id: string; cwd: string; command: string; live: boolean; status: string } };
|
||
expect(session).toMatchObject({ cwd: tmpdir(), command: 'bash', live: true, status: 'running' });
|
||
|
||
const list = await t.bundle.app.inject({
|
||
method: 'GET',
|
||
url: '/api/v1/sessions',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
const sessions = (list.json() as { sessions: Array<{ id: string }> }).sessions;
|
||
expect(sessions.some((s) => s.id === session.id)).toBe(true);
|
||
});
|
||
|
||
it('DELETE /sessions/:id inconnu → 404', async () => {
|
||
const res = await t.bundle.app.inject({
|
||
method: 'DELETE',
|
||
url: '/api/v1/sessions/unknown-id',
|
||
headers: { authorization: `Bearer ${t.token}` },
|
||
});
|
||
expect(res.statusCode).toBe(404);
|
||
});
|
||
|
||
it('logout → cookie effacé', async () => {
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/logout', cookies: { arb_session: cookieValue } });
|
||
expect(res.statusCode).toBe(200);
|
||
const cleared = res.cookies.find((c) => c.name === 'arb_session');
|
||
expect(cleared?.value).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('app e2e — rate limit du login', () => {
|
||
it('mauvais token et bodies invalides → 401, puis 429 dès le 4e essai (backoff après 3 échecs consécutifs)', async () => {
|
||
const t = makeApp('ratelimit-failures');
|
||
const bad = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||
expect(bad.statusCode).toBe(401);
|
||
expect(bad.json()).toMatchObject({ error: { code: 'BAD_TOKEN' } });
|
||
const noToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: {} });
|
||
expect(noToken.statusCode).toBe(401);
|
||
const numToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 42 } });
|
||
expect(numToken.statusCode).toBe(401);
|
||
|
||
// 3 échecs consécutifs → verrou exponentiel : le backoff bloque AVANT la fenêtre 5/min
|
||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
|
||
expect(blocked.statusCode).toBe(429);
|
||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||
// même un login avec le BON token est bloqué pendant le backoff
|
||
const goodButLocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(goodButLocked.statusCode).toBe(429);
|
||
});
|
||
|
||
it('limite 5 tentatives/minute : la 6e → 429 même avec le bon token', async () => {
|
||
const t = makeApp('ratelimit-window');
|
||
for (let i = 0; i < 5; i++) {
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(res.statusCode, `tentative ${i + 1}`).toBe(200);
|
||
}
|
||
const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
|
||
expect(blocked.statusCode).toBe(429);
|
||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||
});
|
||
});
|
||
|
||
describe('app e2e — découverte, resume & fork (P2)', () => {
|
||
let t: TestApp;
|
||
let projectsDir: string;
|
||
let sessionsDir: string;
|
||
|
||
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
|
||
|
||
function writeDiscoveredJsonl(cwd: string, sid: string): void {
|
||
const d = join(projectsDir, munge(cwd));
|
||
mkdirSync(d, { recursive: true });
|
||
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
|
||
}
|
||
|
||
beforeAll(() => {
|
||
t = makeApp('resume');
|
||
projectsDir = join(dir, 'resume-claude', 'projects');
|
||
sessionsDir = join(dir, 'resume-claude', 'sessions');
|
||
mkdirSync(sessionsDir, { recursive: true });
|
||
});
|
||
|
||
it('resume / fork d’un id inconnu → 404', async () => {
|
||
const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
|
||
expect(r.statusCode).toBe(404);
|
||
const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
|
||
expect(f.statusCode).toBe(404);
|
||
});
|
||
|
||
it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
|
||
writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
|
||
await t.bundle.discovery.refresh();
|
||
|
||
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
|
||
const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
|
||
(s) => s.id === 'dead-e2e',
|
||
);
|
||
expect(found).toMatchObject({ source: 'discovered', resumable: true });
|
||
|
||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
|
||
expect(res.statusCode).toBe(201);
|
||
expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
|
||
});
|
||
|
||
it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
|
||
const sid = 'live-e2e';
|
||
writeDiscoveredJsonl(dir, sid);
|
||
const ps = readProcStart(process.pid) as string;
|
||
writeFileSync(
|
||
join(sessionsDir, `${process.pid}.json`),
|
||
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
|
||
);
|
||
await t.bundle.discovery.refresh();
|
||
|
||
const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
|
||
expect(resume.statusCode).toBe(409);
|
||
expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });
|
||
|
||
const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
|
||
expect(fork.statusCode).toBe(201);
|
||
});
|
||
});
|