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).
144 lines
5.8 KiB
TypeScript
144 lines
5.8 KiB
TypeScript
// 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);
|
||
});
|
||
});
|