feat: onglets Réglages & Aide + icône Gitea
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).
This commit is contained in:
@@ -19,6 +19,7 @@ import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerGroupRoutes } from './routes/groups.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
|
||||
@@ -98,6 +99,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerGroupRoutes(app, groups);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||
registerFsRoutes(app);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
|
||||
@@ -35,11 +35,60 @@ export class AuthService {
|
||||
}
|
||||
|
||||
createToken(label: string): string {
|
||||
return this.createTokenRecord(label).token;
|
||||
}
|
||||
|
||||
/** Comme createToken mais renvoie aussi l'id (pour l'API de gestion des tokens). */
|
||||
createTokenRecord(label: string): { id: string; token: string } {
|
||||
const id = randomUUID();
|
||||
const raw = `arb_${randomBytes(24).toString('hex')}`;
|
||||
this.db
|
||||
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
|
||||
return raw;
|
||||
.run(id, label, sha256(raw), new Date().toISOString());
|
||||
return { id, token: raw };
|
||||
}
|
||||
|
||||
/** Tokens actifs (non révoqués), du plus ancien au plus récent. Ne renvoie JAMAIS le hash. */
|
||||
listTokens(): Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }> {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt FROM auth_tokens WHERE revoked_at IS NULL ORDER BY created_at',
|
||||
)
|
||||
.all() as Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }>;
|
||||
}
|
||||
|
||||
/** Nombre de tokens actifs (non révoqués). */
|
||||
countActiveTokens(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoque un token. Refuse de révoquer le DERNIER token actif (sinon lock-out total) → 'last'.
|
||||
* 'ok' = révoqué ; 'not_found' = id inconnu ou déjà révoqué.
|
||||
*/
|
||||
revokeToken(id: string): 'ok' | 'last' | 'not_found' {
|
||||
// Transaction : le check « dernier token » et l'UPDATE doivent être atomiques (garde
|
||||
// anti lock-out robuste, même si un refactor futur introduisait de la concurrence).
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const row = this.db.prepare('SELECT id FROM auth_tokens WHERE id = ? AND revoked_at IS NULL').get(id) as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
let result: 'ok' | 'last' | 'not_found';
|
||||
if (!row) {
|
||||
result = 'not_found';
|
||||
} else if (this.countActiveTokens() <= 1) {
|
||||
result = 'last';
|
||||
} else {
|
||||
this.db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
||||
result = 'ok';
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
verifyRawToken(raw: string): AuthContext | null {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import type {
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MeResponse,
|
||||
TokensListResponse,
|
||||
} from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
|
||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||
@@ -41,7 +48,12 @@ export function registerAuthRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
|
||||
const res: MeResponse = {
|
||||
ok: true,
|
||||
tokenId: req.authContext?.tokenId ?? '',
|
||||
tokenLabel: req.authContext?.label ?? 'unknown',
|
||||
serverVersion,
|
||||
};
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
@@ -50,4 +62,35 @@ export function registerAuthRoutes(
|
||||
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Gestion des tokens d'accès (onglet Réglages) ----
|
||||
// Sous l'auth globale (preValidation). On ne renvoie jamais le hash ; la valeur en clair
|
||||
// d'un nouveau token n'est exposée qu'une seule fois, à la création.
|
||||
app.get('/api/v1/auth/tokens', async (req): Promise<TokensListResponse> => {
|
||||
const current = req.authContext?.tokenId;
|
||||
return { tokens: auth.listTokens().map((t) => ({ ...t, current: t.id === current })) };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/tokens', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateTokenRequest> | null;
|
||||
const label = typeof body?.label === 'string' ? body.label.trim() : '';
|
||||
if (label.length < 1 || label.length > 64) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
||||
}
|
||||
const { id, token } = auth.createTokenRecord(label);
|
||||
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = auth.revokeToken(id);
|
||||
if (result === 'not_found') {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
||||
}
|
||||
if (result === 'last') {
|
||||
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
||||
}
|
||||
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
67
packages/server/src/routes/settings.ts
Normal file
67
packages/server/src/routes/settings.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings`
|
||||
// contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs
|
||||
// non sensibles et n'écrivent QUE des clés explicitement allow-listées — jamais les secrets.
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
||||
import type { Config } from '../config.js';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
|
||||
// Seule clé de `settings` modifiable via l'API. Les secrets ne figurent JAMAIS ici.
|
||||
const GITEA_URL_KEY = 'gitea_url';
|
||||
|
||||
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
|
||||
function normalizeGiteaUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function registerSettingsRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
config: Config,
|
||||
serverVersion: string,
|
||||
push: PushService,
|
||||
): void {
|
||||
// '' (effacé) est normalisé en null côté réponse.
|
||||
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
|
||||
const serverInfo = (): ServerInfo => ({
|
||||
version: serverVersion,
|
||||
port: config.port,
|
||||
bind: config.bind,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
dataDir: config.dataDir,
|
||||
vapidPublicKey: push.publicKey() || null,
|
||||
vapidContact: config.vapidContact,
|
||||
});
|
||||
const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl() }, server: serverInfo() });
|
||||
|
||||
app.get('/api/v1/settings', async (): Promise<SettingsResponse> => snapshot());
|
||||
|
||||
app.patch('/api/v1/settings', async (req, reply) => {
|
||||
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
|
||||
if ('giteaUrl' in body) {
|
||||
const v = body.giteaUrl;
|
||||
if (v === null || v === '') {
|
||||
setSetting(db, GITEA_URL_KEY, ''); // effacement
|
||||
} else if (typeof v === 'string') {
|
||||
const normalized = normalizeGiteaUrl(v);
|
||||
if (!normalized) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } });
|
||||
}
|
||||
setSetting(db, GITEA_URL_KEY, normalized);
|
||||
} else {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
|
||||
}
|
||||
}
|
||||
return reply.send(snapshot());
|
||||
});
|
||||
}
|
||||
@@ -127,7 +127,8 @@ describe('app e2e — auth, origin et sessions', () => {
|
||||
cookies: { arb_session: cookieValue },
|
||||
});
|
||||
expect(me.statusCode).toBe(200);
|
||||
expect(me.json()).toEqual({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
|
||||
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 () => {
|
||||
|
||||
143
packages/server/test/auth-tokens.test.ts
Normal file
143
packages/server/test/auth-tokens.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
126
packages/server/test/settings-routes.test.ts
Normal file
126
packages/server/test/settings-routes.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
|
||||
// l'allow-list et valide l'URL Gitea (http/https only, anti-XSS).
|
||||
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 { getSetting, openDb, type Db } from '../src/db/index.js';
|
||||
import type { Config } from '../src/config.js';
|
||||
import type { SettingsResponse } from '@arboretum/shared';
|
||||
|
||||
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-settings-'));
|
||||
const dbPath = join(dir, 'settings.db');
|
||||
db = openDb(dbPath);
|
||||
const config: Config = {
|
||||
port: 9999,
|
||||
bind: '127.0.0.1',
|
||||
dbPath,
|
||||
dataDir: dir,
|
||||
allowedOrigins: ['https://host.tailnet.ts.net'],
|
||||
printToken: false,
|
||||
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||
vapidContact: 'mailto:test@localhost',
|
||||
};
|
||||
bundle = buildApp(config, db, '1.2.3-test');
|
||||
const t = bundle.auth.ensureBootstrapToken();
|
||||
if (!t) throw new Error('bootstrap token attendu');
|
||||
token = t;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bundle.app.close();
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/v1/settings', () => {
|
||||
it('renvoie la config serveur non sensible et giteaUrl null par défaut', async () => {
|
||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as SettingsResponse;
|
||||
expect(body.server.version).toBe('1.2.3-test');
|
||||
expect(body.server.port).toBe(9999);
|
||||
expect(body.server.bind).toBe('127.0.0.1');
|
||||
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
|
||||
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
|
||||
expect(body.settings.giteaUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => {
|
||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||
const raw = res.body;
|
||||
const secret = getSetting(db, 'server_secret');
|
||||
const vapidPrivate = getSetting(db, 'vapid_private');
|
||||
expect(secret).toBeTruthy();
|
||||
expect(raw).not.toContain(secret as string);
|
||||
expect(raw).not.toContain(vapidPrivate as string);
|
||||
expect(raw).not.toMatch(/server_secret|vapid_private|privateKey/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/settings', () => {
|
||||
it('enregistre une URL Gitea valide et la renvoie', async () => {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
||||
// persisté
|
||||
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||
expect((get.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
||||
});
|
||||
|
||||
it('efface l’URL avec null ou chaîne vide', async () => {
|
||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: null } });
|
||||
expect((res.json() as SettingsResponse).settings.giteaUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('rejette une URL non http(s) — anti-XSS (400)', async () => {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'javascript:alert(1)' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
const notUrl = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'pas une url' } });
|
||||
expect(notUrl.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
|
||||
const before = getSetting(db, 'server_secret');
|
||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
|
||||
expect(getSetting(db, 'server_secret')).toBe(before); // inchangé
|
||||
expect(getSetting(db, 'vapid_private')).not.toBe('pwned');
|
||||
});
|
||||
|
||||
it('sans authentification → 401', async () => {
|
||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { giteaUrl: 'https://x.example' } });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user