CI / build-and-test (push) Successful in 15m9s
Le lien « API » du menu ouvre /api/docs (et /proxy/api/docs) : Swagger UI
auto-hébergé (swagger-ui-dist, aucun CDN, thème sombre), spécification chargée
depuis `${base}/openapi.json`.
/openapi.json devient exhaustif :
- inventaire généré depuis l'arbre Express (collectApiRoutes) : `:param` →
`{param}`, `*` → `{path}`, miroirs /proxy/* et métadonnées écartés, préfixe
/api retiré (le document porte les serveurs /api et /proxy/api) ;
- `app.all()` réduit à GET — ses 35 méthodes HTTP (acl, propfind, m-search…)
entraient dans le document et figaient Swagger au bout de 2 groupes ;
- authentification marquée automatiquement (middlewares de route ET de préfixe,
ex. `r.use('/download', …)`), route publique laissée publique ;
- tags + descriptions de groupe, résumés FR pour les routes courantes ;
- 3 schémas d'auth : bearerAuth (JWT), apiKeyAuth (X-API-Key), cookieAuth ;
- description : méthodes d'auth avec exemples, codes d'erreur et seuils de
débit, section « Serveur MCP » avec snippet `mcpServers` et la liste des
outils LUE dans mcp/server.mjs au démarrage (aucune liste à maintenir).
Dockerfile : `mcp/` copié dans l'image (cette lecture doit exister en prod).
package.json → 1.0.61 (tag de cette livraison).
Tests : api_coverage +3 cas (page /docs + assets, exhaustivité/méthodes/tags/
sécurité, 3 schémas + outils MCP dans la description) → 51/51 verts.
Vérifs instance locale : lien menu = /proxy/api/docs, 114 opérations rendues
sur 12 groupes, bouton Authorize + section MCP visibles, zéro erreur console.
369 lines
20 KiB
JavaScript
369 lines
20 KiB
JavaScript
// Couverture HTTP des routes API non couvertes par les autres suites :
|
|
// health, search/suggest (400 + fallback), transcript (400), trending (400 + shape),
|
|
// ai/status, openapi, auth (register/login/me), preferences, playlists CRUD,
|
|
// likes, history search/watch, subscriptions, telemetry, download jobs.
|
|
// Offline-safe : aucune assertion sur le contenu provider externe, uniquement
|
|
// les formes stables (status, clés JSON). yt-dlp n'est jamais invoqué avec succès.
|
|
// Run: npm run test:api
|
|
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import net from 'node:net';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-api-cov-'));
|
|
const dbPath = path.join(tmpDir, 'cov.db');
|
|
const PORT = await new Promise((resolve) => {
|
|
const s = net.createServer();
|
|
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
|
|
});
|
|
const base = `http://127.0.0.1:${PORT}`;
|
|
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
|
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
|
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: dbPath, JWT_SECRET: 'cov-test-secret', NODE_ENV: 'test',
|
|
// P0 : sentinel — cette clé ne doit JAMAIS apparaître dans ce que le
|
|
// serveur livre au navigateur (config.local.js générée).
|
|
YOUTUBE_API_KEY: 'SENTINEL_YT_KEY_MUST_NOT_LEAK' },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let logs = '';
|
|
server.stdout.on('data', (d) => { logs += d; });
|
|
server.stderr.on('data', (d) => { logs += d; });
|
|
|
|
async function waitUp(timeout = 25000) {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < timeout) {
|
|
try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return true; } catch {}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
return false;
|
|
}
|
|
const up = await waitUp();
|
|
assert.ok(up, `API ne démarre pas:\n${logs.slice(-3000)}`);
|
|
|
|
const J = async (url, opts = {}) => {
|
|
const r = await fetch(url, opts);
|
|
const body = await r.json().catch(() => ({}));
|
|
return { status: r.status, body };
|
|
};
|
|
const auth = (t) => ({ Authorization: `Bearer ${t}` });
|
|
const uniq = (p) => `${p}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
|
|
|
|
let token;
|
|
const user = uniq('covuser');
|
|
|
|
before(async () => {
|
|
const reg = await J(`${base}/api/auth/register`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
|
|
});
|
|
assert.ok([201, 409].includes(reg.status), `register ${reg.status}`);
|
|
const login = await J(`${base}/api/auth/login`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
|
|
});
|
|
assert.equal(login.status, 200);
|
|
assert.ok(login.body.accessToken);
|
|
token = login.body.accessToken;
|
|
});
|
|
after(() => { try { server.kill(); } catch {} });
|
|
|
|
describe('lecture publique / santé', () => {
|
|
it('GET /api/health -> ok', async () => {
|
|
const r = await J(`${base}/api/health`);
|
|
assert.equal(r.status, 200); assert.equal(r.body.status, 'ok');
|
|
});
|
|
it('GET /healthz expose youtube.mode sans secret', async () => {
|
|
const r = await J(`${base}/healthz`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.status, 'ok');
|
|
assert.ok(r.body.youtube?.mode);
|
|
assert.ok(!JSON.stringify(r.body).includes('GEMINI'));
|
|
});
|
|
it('GET /api/search?q=x -> 400', async () => {
|
|
const r = await J(`${base}/api/search?q=x`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/search providers inconnus -> fallback registre', async () => {
|
|
const r = await J(`${base}/api/search?q=test&providers=xx,yy&pageSize=2`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.providers.length >= 6);
|
|
assert.ok(r.body.groups && typeof r.body.groups === 'object');
|
|
});
|
|
it('GET /api/search/suggest?q=x -> 400', async () => {
|
|
const r = await J(`${base}/api/search/suggest?q=x`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/transcript provider inconnu -> 400 available:false', async () => {
|
|
const r = await J(`${base}/api/transcript/bogus/vid`);
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.available, false);
|
|
});
|
|
it('GET /api/details provider inconnu -> 500 details_failed (sans yt-dlp)', async () => {
|
|
const r = await J(`${base}/api/details/bogus/vid`);
|
|
assert.equal(r.status, 500);
|
|
assert.equal(r.body.error, 'details_failed');
|
|
});
|
|
it('GET /api/trending provider non-yt -> 400', async () => {
|
|
const r = await J(`${base}/api/trending?provider=dm`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/trending?provider=yt -> shape {provider,items[]}', async () => {
|
|
const r = await J(`${base}/api/trending?provider=yt&limit=2`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.provider, 'yt');
|
|
assert.ok(Array.isArray(r.body.items));
|
|
});
|
|
it('GET /api/ai/status -> {ready:boolean}', async () => {
|
|
const r = await J(`${base}/api/ai/status`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(typeof r.body.ready, 'boolean');
|
|
});
|
|
it('GET /api/openapi.json documente playlists', async () => {
|
|
const r = await J(`${base}/api/openapi.json`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.paths?.['/playlists']);
|
|
});
|
|
});
|
|
|
|
describe('auth + espace utilisateur', () => {
|
|
it('register/login 400 sans password, 401 mauvais password', async () => {
|
|
const r1 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user }) });
|
|
assert.equal(r1.status, 400);
|
|
const r2 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'mauvais' }) });
|
|
assert.equal(r2.status, 401);
|
|
});
|
|
it('protégées sans token -> 401', async () => {
|
|
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) {
|
|
const r = await J(`${base}${u}`);
|
|
assert.equal(r.status, 401, u);
|
|
}
|
|
});
|
|
it('GET /api/user/me + preferences round-trip', async () => {
|
|
const me = await J(`${base}/api/user/me`, { headers: auth(token) });
|
|
assert.equal(me.status, 200); assert.ok(me.body.id);
|
|
const p = await J(`${base}/api/user/preferences`, { headers: auth(token) });
|
|
assert.equal(p.status, 200);
|
|
const w = await J(`${base}/api/user/preferences`, { method: 'PATCH', headers: { ...auth(token), 'content-type': 'application/json' }, body: JSON.stringify({ defaultProviders: ['yt'] }) });
|
|
assert.equal(w.status, 200);
|
|
});
|
|
it('playlists CRUD complet', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const c = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({ title: 'Cov' }) });
|
|
assert.equal(c.status, 201); const id = c.body.id; assert.ok(id);
|
|
const bad = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({}) });
|
|
assert.equal(bad.status, 400);
|
|
const g = await J(`${base}/api/playlists/${id}`, { headers: auth(token) });
|
|
assert.equal(g.status, 200);
|
|
const add = await J(`${base}/api/playlists/${id}/videos`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
|
|
assert.equal(add.status, 201);
|
|
const del = await J(`${base}/api/playlists/${id}/videos/v1?provider=youtube`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(del.status, 200);
|
|
const u = await J(`${base}/api/playlists/${id}`, { method: 'PUT', headers: h, body: JSON.stringify({ title: 'Cov2' }) });
|
|
assert.equal(u.status, 200); assert.equal(u.body.title, 'Cov2');
|
|
const rm = await fetch(`${base}/api/playlists/${id}`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(rm.status, 204);
|
|
});
|
|
it('likes + history search/watch', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
// title + thumbnail fournis -> pas d'appel yt-dlp d'enrichissement (offline-safe)
|
|
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
|
|
assert.ok([200, 201].includes(like.status));
|
|
const st = await J(`${base}/api/user/likes/status?provider=youtube&videoId=v1`, { headers: auth(token) });
|
|
assert.equal(st.status, 200);
|
|
const hs = await J(`${base}/api/user/history/search`, { method: 'POST', headers: h, body: JSON.stringify({ query: 'cov' }) });
|
|
assert.ok([200, 201].includes(hs.status));
|
|
const ls = await J(`${base}/api/user/history/search`, { headers: auth(token) });
|
|
assert.equal(ls.status, 200);
|
|
const hw = await J(`${base}/api/user/history/watch`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1' }) });
|
|
assert.ok([200, 201].includes(hw.status));
|
|
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
|
|
assert.equal(lw.status, 200);
|
|
});
|
|
it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) });
|
|
assert.equal(add.status, 201, JSON.stringify(add.body));
|
|
const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
|
assert.equal(st.status, 200);
|
|
assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later');
|
|
// La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later
|
|
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) });
|
|
assert.ok([200, 201].includes(like.status));
|
|
const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) });
|
|
assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later');
|
|
const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) });
|
|
assert.equal(list.status, 200);
|
|
assert.ok(Array.isArray(list.body), 'watch-later repond une liste');
|
|
assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste');
|
|
assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later');
|
|
const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(rm.status, 200);
|
|
assert.equal(rm.body.removed, true);
|
|
const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
|
assert.equal(st3.body.liked, false, 'wl1 retire de la liste');
|
|
const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) });
|
|
assert.equal(bad.status, 400);
|
|
});
|
|
it('subscriptions + telemetry + download jobs', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });
|
|
assert.ok([200, 201].includes(sub.status));
|
|
const ls = await J(`${base}/api/subscriptions`, { headers: auth(token) });
|
|
assert.equal(ls.status, 200);
|
|
const tel = await J(`${base}/api/telemetry/events`, { method: 'POST', headers: h, body: JSON.stringify({ event: 'search_submit' }) });
|
|
assert.ok([200, 201].includes(tel.status));
|
|
const dj = await J(`${base}/api/download/jobs`, { headers: auth(token) });
|
|
assert.equal(dj.status, 200);
|
|
});
|
|
});
|
|
|
|
// Production-ready (P0 + P1) : secrets hors navigateur, CORS configurable,
|
|
// clés d'API longue durée, identifiant de requête, métriques, contrat/version.
|
|
describe('production-ready (P0/P1)', () => {
|
|
it('config.local.js générée : aucun secret n\'est livré au navigateur', async () => {
|
|
const r = await fetch(`${base}/assets/config.local.js`);
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.headers.get('content-type') || '', /javascript/);
|
|
const body = await r.text();
|
|
for (const s of ['SENTINEL_YT_KEY_MUST_NOT_LEAK', 'YOUTUBE_API_KEY', 'YOUTUBE_API_KEYS',
|
|
'TWITCH_CLIENT_SECRET', 'GEMINI_API_KEY', 'RUMBLE_API_KEY', 'VIMEO_ACCESS_TOKEN']) {
|
|
assert.ok(!body.includes(s), `${s} ne doit pas figurer dans la config servie`);
|
|
}
|
|
});
|
|
|
|
it('X-Request-Id renvoyé sur chaque réponse', async () => {
|
|
const r = await fetch(`${base}/api/health`);
|
|
assert.ok(r.headers.get('x-request-id'), 'header X-Request-Id absent');
|
|
});
|
|
|
|
it('GET /metrics : exposition Prometheus', async () => {
|
|
const r = await fetch(`${base}/metrics`);
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.headers.get('content-type') || '', /text\/plain/);
|
|
const body = await r.text();
|
|
assert.ok(body.includes('http_requests_total'), 'compteur http_requests_total absent');
|
|
assert.ok(body.includes('http_request_duration_ms_sum'), 'durées absentes');
|
|
assert.ok(body.includes('process_uptime_seconds'), 'jauge processus absente');
|
|
});
|
|
|
|
it('CORS : PATCH autorisé et X-API-Key dans les en-têtes acceptés', async () => {
|
|
const r = await fetch(`${base}/api/user/me`, {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
Origin: 'http://localhost:4200',
|
|
'Access-Control-Request-Method': 'PATCH',
|
|
'Access-Control-Request-Headers': 'authorization,content-type',
|
|
},
|
|
});
|
|
assert.ok(r.status < 300, `preflight ${r.status}`);
|
|
assert.equal(r.headers.get('access-control-allow-origin'), 'http://localhost:4200');
|
|
assert.match(r.headers.get('access-control-allow-methods') || '', /PATCH/);
|
|
assert.match(r.headers.get('access-control-allow-headers') || '', /X-API-Key/i);
|
|
});
|
|
|
|
it('openapi : version = package.json + contrats détaillés + schéma apiKeyAuth', async () => {
|
|
const pkg = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
|
const r = await J(`${base}/api/openapi.json`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.info.version, pkg.version, 'info.version doit être la version package.json');
|
|
assert.ok(r.body.paths['/details/{provider}/{videoId}'], 'contrat /details absent');
|
|
assert.ok(r.body.paths['/transcript/{provider}/{videoId}'], 'contrat /transcript absent');
|
|
assert.ok(r.body.paths['/playlists'], 'contrat /playlists absent');
|
|
assert.ok(r.body.paths['/keys'], 'contrat /keys absent');
|
|
assert.ok(r.body.components.securitySchemes.apiKeyAuth, 'schéma apiKeyAuth absent');
|
|
});
|
|
|
|
it('clés d\'API : création, usage hors navigateur, listage sans jeton, révocation', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const created = await J(`${base}/api/keys`, { method: 'POST', headers: h, body: JSON.stringify({ name: 'cov' }) });
|
|
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
assert.ok(String(created.body.token).startsWith('ntk_'), 'format de jeton');
|
|
const keyId = created.body.id;
|
|
|
|
// Le jeton ouvre les routes protégées sans Authorization.
|
|
const me = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': created.body.token } });
|
|
assert.equal(me.status, 200, 'X-API-Key non accepté');
|
|
|
|
// Listage : préfixe affichable, jeton jamais renvoyé.
|
|
const list = await J(`${base}/api/keys`, { headers: auth(token) });
|
|
assert.equal(list.status, 200);
|
|
const row = (list.body.items || []).find((k) => k.id === keyId);
|
|
assert.ok(row, 'clé absente du listage');
|
|
assert.equal(row.prefix, created.body.token.slice(0, 11));
|
|
assert.ok(!JSON.stringify(list.body).includes(created.body.token), 'jeton exposé par le listage');
|
|
|
|
// Révocation immédiate + idempotence en 404.
|
|
const del = await J(`${base}/api/keys/${keyId}`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(del.status, 200);
|
|
const after = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': created.body.token } });
|
|
assert.equal(after.status, 401, 'clé révoquée encore acceptée');
|
|
const again = await J(`${base}/api/keys/${keyId}`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(again.status, 404, 'double révocation doit répondre 404');
|
|
});
|
|
|
|
it('clés d\'API : jeton invalide => 401 (et pas de session par magie)', async () => {
|
|
const r = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': 'ntk_invalide' } });
|
|
assert.equal(r.status, 401);
|
|
});
|
|
});
|
|
|
|
// Documentation web : la page /docs (Swagger UI) et l'exhaustivité du contrat.
|
|
describe('documentation web (/docs)', () => {
|
|
it('GET /api/docs sert la page Swagger et ses assets', async () => {
|
|
const r = await fetch(`${base}/api/docs`);
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.headers.get('content-type') || '', /html/);
|
|
const html = await r.text();
|
|
assert.ok(html.includes('swagger-ui-bundle.js'), 'bundle swagger absent de la page');
|
|
assert.ok(html.includes('/api/openapi.json'), 'point d\'entrée spec absent');
|
|
for (const asset of ['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js']) {
|
|
const a = await fetch(`${base}/api/docs/assets/${asset}`);
|
|
assert.equal(a.status, 200, `asset ${asset}`);
|
|
}
|
|
});
|
|
|
|
it('openapi : inventaire exhaustif, méthodes valides, tags partout', async () => {
|
|
const r = await J(`${base}/api/openapi.json`);
|
|
const paths = Object.keys(r.body.paths);
|
|
assert.ok(paths.length >= 80, `seulement ${paths.length} chemins documentés`);
|
|
for (const p of ['/download/jobs', '/user/likes/status', '/rumble/browse', '/auth/sessions',
|
|
'/metrics', '/keys', '/yt/{path}', '/playlists/export', '/providers/health', '/twitch-token']) {
|
|
assert.ok(r.body.paths[p], `${p} absent du contrat`);
|
|
}
|
|
assert.ok(!paths.some((p) => p.includes('/proxy')), 'chemin /proxy/ présent (miroir non documenté)');
|
|
// Régression app.all() : 35 méthodes HTTP (acl, propfind…) ne doivent pas
|
|
// entrer dans le document — Swagger plante et n'affiche plus rien.
|
|
const VALID = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
const invalid = paths.flatMap((p) => Object.keys(r.body.paths[p]).filter((m) => !VALID.includes(m)));
|
|
assert.deepEqual(invalid, [], `méthodes invalides: ${invalid.slice(0, 5).join(', ')}`);
|
|
const noTag = paths.filter((p) => !Object.values(r.body.paths[p]).some((op) => Array.isArray(op.tags) && op.tags.length));
|
|
assert.deepEqual(noTag, [], `opérations sans tag: ${noTag.slice(0, 5).join(', ')}`);
|
|
// Sécurité : route couverte par un middleware de préfixe marquée protégée,
|
|
// route publique laissée telle quelle.
|
|
assert.ok(r.body.paths['/download/jobs'].get.security, '/download/jobs doit être marqué protégé');
|
|
assert.ok(!r.body.paths['/search'].get.security, '/search doit rester public');
|
|
assert.ok(Array.isArray(r.body.tags) && r.body.tags.length >= 10, 'descriptions de tags absentes');
|
|
});
|
|
|
|
it('openapi : 3 schémas d\'authentification + MCP décrit avec ses outils', async () => {
|
|
const r = await J(`${base}/api/openapi.json`);
|
|
const schemes = r.body.components.securitySchemes;
|
|
assert.ok(schemes.bearerAuth && schemes.apiKeyAuth && schemes.cookieAuth, '3 schémas d\'auth attendus');
|
|
const desc = r.body.info.description;
|
|
assert.ok(desc.includes('Authorization: Bearer'), 'méthode JWT non décrite');
|
|
assert.ok(desc.includes('X-API-Key'), 'méthode clé d\'API non décrite');
|
|
assert.ok(desc.includes('Serveur MCP') && desc.includes('mcp/server.mjs'), 'section MCP absente');
|
|
// La liste des outils est lue dans mcp/server.mjs : aucun décalage possible.
|
|
const mcp = fs.readFileSync(path.resolve(import.meta.dirname, '..', '..', 'mcp', 'server.mjs'), 'utf8');
|
|
const start = mcp.indexOf('const TOOLS = [');
|
|
assert.ok(start > 0, 'bloc TOOLS introuvable dans mcp/server.mjs');
|
|
const names = [...mcp.slice(start, mcp.indexOf('\n];', start)).matchAll(/name:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]);
|
|
assert.ok(names.length >= 10, `outils MCP trouvés: ${names.length}`);
|
|
for (const n of names) assert.ok(desc.includes(n), `outil MCP ${n} absent de la description`);
|
|
});
|
|
});
|