feat(docs): documentation web des API (Swagger UI) liée depuis le menu
CI / build-and-test (push) Successful in 15m9s
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.
This commit is contained in:
+290
-4
@@ -4191,6 +4191,260 @@ r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Documentation web (Swagger UI) + OpenAPI exhaustif ---------------------
|
||||
// Le lien « API » du menu ouvre /docs : Swagger UI auto-hébergé (swagger-ui-dist)
|
||||
// branché sur /openapi.json, qui décrit TOUTES les routes du serveur.
|
||||
const SWAGGER_DIR = path.join(process.cwd(), 'node_modules', 'swagger-ui-dist');
|
||||
r.use('/docs/assets', express.static(SWAGGER_DIR, { index: false, fallthrough: false }));
|
||||
|
||||
r.get('/docs', (req, res) => {
|
||||
const base = req.baseUrl || '/api'; // /api ou /proxy/api (les deux préfixes du router)
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NewTube — Documentation API</title>
|
||||
<link rel="icon" type="image/png" href="${base}/docs/assets/favicon-32x32.png">
|
||||
<link rel="stylesheet" href="${base}/docs/assets/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; background: #0b1220; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; }
|
||||
.docs-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
padding: .7rem 1.25rem; background: #0f172a; border-bottom: 1px solid #1e293b; font-size: .9rem; }
|
||||
.docs-bar a { color: #93c5fd; text-decoration: none; }
|
||||
.docs-bar a:hover { text-decoration: underline; }
|
||||
.swagger-ui { max-width: 1120px; margin: 0 auto; padding: 1rem 1rem 3rem; }
|
||||
.swagger-ui .topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="docs-bar">
|
||||
<strong>NewTube — Documentation API</strong>
|
||||
<span>
|
||||
<a href="${base}/openapi.json">openapi.json</a> ·
|
||||
<a href="${base}/metrics">/metrics</a> ·
|
||||
<a href="/">← retour à l'application</a>
|
||||
</span>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="${base}/docs/assets/swagger-ui-bundle.js" charset="UTF-8"></script>
|
||||
<script src="${base}/docs/assets/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: ${JSON.stringify(`${base}/openapi.json`)},
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true,
|
||||
docExpansion: 'list',
|
||||
filter: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||
layout: 'StandaloneLayout'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
// ---- Inventaire automatique des routes (source de vérité : Express) --------
|
||||
// Les ~20 entrées détaillées du document sont écrites à la main (paramètres,
|
||||
// réponses) ; TOUTE le reste est généré à partir de l'arbre Express, ce qui
|
||||
// empêche la doc de dériver du code.
|
||||
/** Montage Express 4 : `layer.regexp.source` = `^\/api\/?(?=\/|$)`. */
|
||||
function expressMount(layer) {
|
||||
if (!layer.regexp || layer.regexp.fast_slash) return '';
|
||||
return String(layer.regexp.source || '')
|
||||
.replace(/^\^/, '')
|
||||
.replace(/\\\/\?\(\?=\\\/\|\$\)$/, '')
|
||||
.replace(/\\\//g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes enregistrées, en chemins OpenAPI (`:id` → `{id}`, `*` → `{path}`),
|
||||
* avec le marquage d'authentification : middlewares posés SUR la route
|
||||
* (authMiddleware*) ET middlewares de préfixe (`r.use('/download', …)`), dans
|
||||
* l'ordre d'enregistrement — la sémantique Express.
|
||||
* Les miroirs /proxy/*, les métadonnées (assets, config) et la page de doc
|
||||
* sont écartés : le document est décrit sous le serveur `/api`.
|
||||
*/
|
||||
function collectApiRoutes(stack, prefix, out, authPrefixes = [], depth = 0) {
|
||||
if (!stack || depth > 4) return out;
|
||||
for (const layer of stack) {
|
||||
if (layer.route) {
|
||||
const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
|
||||
const ownAuth = (layer.route.stack || []).some((l) => l?.handle?.name === 'authMiddleware'
|
||||
|| l?.handle?.name === 'authMiddlewareCookieAware');
|
||||
for (const p of paths) {
|
||||
const full = `${prefix}${p}`;
|
||||
if (full.includes('...')) continue; // route d'placeholder
|
||||
if (full.startsWith('/proxy')) continue; // miroir dev/prod
|
||||
if (full === '/*' || full === '*') continue; // fallback SPA
|
||||
// Le document est servi avec le serveur `/api` : on retire ce préfixe
|
||||
// quand la route est écrite en dur dessus (`/api/search` → `/search`).
|
||||
const rel = full.startsWith('/api/') ? full.slice(4) : full;
|
||||
if (rel.startsWith('/assets') || rel === '/config.js') continue; // config navigateur
|
||||
if (rel === '/docs' || rel.startsWith('/docs/')) continue; // méta-doc
|
||||
const oa = rel.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\*/g, '{path}');
|
||||
const auth = ownAuth || authPrefixes.some((a) => full.startsWith(a));
|
||||
const registered = Object.keys(layer.route.methods || {});
|
||||
// `app.all()` enregistre les 35 méthodes HTTP : OpenAPI n'accepte que
|
||||
// les standard, et Swagger plante sur `acl`, `m-search`, `propfind`…
|
||||
const methods = registered.length > 7
|
||||
? ['get']
|
||||
: registered.filter((m) => ['get', 'post', 'put', 'patch', 'delete'].includes(m));
|
||||
for (const m of methods) {
|
||||
out.push({ method: m.toUpperCase(), path: oa, auth });
|
||||
}
|
||||
}
|
||||
} else if (layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack)) {
|
||||
collectApiRoutes(layer.handle.stack, `${prefix}${expressMount(layer)}`, out, [...authPrefixes], depth + 1);
|
||||
} else if (layer.regexp && (layer.handle?.name === 'authMiddleware'
|
||||
|| layer.handle?.name === 'authMiddlewareCookieAware')) {
|
||||
// Middleware d'auth posé sur un préfixe : il couvre tout ce qui suit.
|
||||
authPrefixes.push(prefix + expressMount(layer));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Libellés FR des routes générées (les entrées détaillées portent les leurs). */
|
||||
const API_SUMMARIES = {
|
||||
'GET /healthz': 'Santé détaillée (mode YouTube, yt-dlp, cache, quota, clés masquées)',
|
||||
'GET /health': 'Healthcheck court (Docker)',
|
||||
'GET /metrics': 'Métriques Prometheus (compteurs HTTP, durées, processus)',
|
||||
'GET /search': 'Recherche unifiée multi-providers (filtres, pagination)',
|
||||
'GET /trending': 'Tendances YouTube (scrape, sans clé)',
|
||||
'GET /ai/status': 'État du résumé IA (Gemini configuré ou non)',
|
||||
'POST /ai/summarize': 'Résumé IA d\'une vidéo',
|
||||
'GET /twitch-token': 'Jeton applicatif Twitch (client credentials côté serveur)',
|
||||
'GET /twitch/top-streams': 'Top directs Twitch',
|
||||
'GET /openapi.json': 'Spécification OpenAPI JSON (source de cette page)',
|
||||
'GET /playlists/export': 'Exporter toutes les playlists (JSON)',
|
||||
'POST /playlists/import': 'Importer des playlists (JSON, dédoublonnage par titre)',
|
||||
'GET /playlists/public': 'Playlists publiques',
|
||||
'GET /playlists/{id}/view': 'Compteur de vues d\'une playlist',
|
||||
'POST /download/{provider}/{videoId}': 'Téléverser une vidéo (crée un job)',
|
||||
'GET /download/jobs': 'Lister les jobs de téléchargement',
|
||||
'GET /download/jobs/{id}': 'État d\'un job',
|
||||
'GET /download/jobs/{id}/file': 'Récupérer le fichier téléchargé',
|
||||
'POST /download/jobs/{id}/retry': 'Reprendre un job échoué',
|
||||
'DELETE /download/jobs/{id}': 'Annuler et supprimer un job',
|
||||
'GET /user/likes/status': 'Statut « liké » d\'une vidéo',
|
||||
'GET /user/watch-later/status': 'Statut « à regarder plus tard » d\'une vidéo',
|
||||
'GET /user/history/search': 'Historique de recherche',
|
||||
'GET /user/history/watch': 'Historique de visionnage',
|
||||
'GET /user/history/transcripts': 'Transcripts sauvegardés',
|
||||
'POST /user/history/takeout': 'Exporter son historique (takeout)',
|
||||
'GET /auth/sessions': 'Sessions connectées',
|
||||
'DELETE /auth/sessions/{id}': 'Révoquer une session',
|
||||
'POST /auth/register': 'Créer un compte',
|
||||
'POST /auth/login': 'Connexion (accessToken + cookies httpOnly)',
|
||||
'POST /auth/refresh': 'Renouveler l\'accessToken via les cookies',
|
||||
'POST /auth/logout': 'Déconnexion',
|
||||
'GET /channels/{provider}/{externalId}': 'Méta-données d\'une chaîne (avatar, abonnés)',
|
||||
'GET /channels/{provider}/{externalId}/content': 'Vidéos d\'une chaîne',
|
||||
'POST /channels/resolve': 'Résoudre une URL de chaîne en identifiant',
|
||||
'GET /subscriptions': 'Abonnements aux chaînes',
|
||||
'POST /subscriptions': 'S\'abonner à une chaîne',
|
||||
'POST /subscriptions/batch': 'S\'abonner en lot (import OAuth)',
|
||||
'DELETE /subscriptions/{subscriptionId}': 'Se désabonner',
|
||||
'GET /subscription-groups': 'Groupes d\'abonnements (façon PocketTube)',
|
||||
'GET /providers/health': 'Santé des fournisseurs (sonde + métriques 1 h)',
|
||||
'GET /providers/metrics': 'Métriques par fournisseur (recherche, erreurs)',
|
||||
'GET /telemetry/events': 'Événements UX anonymes',
|
||||
'GET /telemetry/summary': 'Résumé des événements UX',
|
||||
'GET /img/odysee': 'Proxy d\'image Odysee',
|
||||
'GET /peertube/{instance}/{path}': 'Proxy générique vers une instance PeerTube',
|
||||
'GET /yt/{path}': 'Proxy YouTube Data API avec clé, rotation et cache serveur',
|
||||
'GET /rumble/browse': 'Rumble : page « browse » (scraping)',
|
||||
'GET /rumble/search': 'Rumble : recherche (scraping)',
|
||||
'GET /rumble/video/{id}': 'Rumble : métadonnées d\'une vidéo',
|
||||
'GET /rumble/live': 'Rumble : directs (page SSR)',
|
||||
'GET /oauth/status': 'Connecteurs OAuth configurés (Google, Twitch)',
|
||||
};
|
||||
|
||||
/** Tags : premier segment du chemin + descriptions affichées par Swagger. */
|
||||
const TAG_OF = {
|
||||
healthz: 'health', health: 'health', metrics: 'health',
|
||||
trending: 'video', details: 'video', transcript: 'video', peertube: 'video',
|
||||
yt: 'video', img: 'video', twitch: 'video',
|
||||
user: 'account', keys: 'account',
|
||||
subscriptions: 'social', 'subscription-groups': 'social',
|
||||
rumble: 'video',
|
||||
dm: 'video', odysee: 'video', 'twitch-api': 'video', 'twitch-auth': 'video', 'twitch-token': 'video',
|
||||
providers: 'admin', telemetry: 'admin', ai: 'admin',
|
||||
openapi: 'meta', 'openapi.json': 'meta',
|
||||
};
|
||||
const TAG_DESCRIPTIONS = [
|
||||
{ name: 'meta', description: 'Découverte de l\'API : spécification OpenAPI, métriques.' },
|
||||
{ name: 'health', description: 'Santé du service et métriques d\'exploitation.' },
|
||||
{ name: 'search', description: 'Recherche unifiée et suggestions multi-providers.' },
|
||||
{ name: 'video', description: 'Lecture : métadonnées, transcripts, tendances, proxies fournisseurs.' },
|
||||
{ name: 'channels', description: 'Chaînes : métadonnées et contenu.' },
|
||||
{ name: 'playlists', description: 'Playlists utilisateur (CRUD, export/import JSON).' },
|
||||
{ name: 'account', description: 'Espace utilisateur : profil, préférences, likes, historique, clés d\'API.' },
|
||||
{ name: 'auth', description: 'Inscription, connexion, sessions, jetons.' },
|
||||
{ name: 'social', description: 'Abonnements aux chaînes et groupes.' },
|
||||
{ name: 'oauth', description: 'Connexions OAuth et import de favoris/abonnements.' },
|
||||
{ name: 'download', description: 'Téléchargements : formats, jobs, fichiers.' },
|
||||
{ name: 'admin', description: 'Administration : santé providers, métriques, IA, télémétrie.' },
|
||||
];
|
||||
|
||||
/** Outils MCP lus dans mcp/server.mjs (aucune liste à maintenir à la main). */
|
||||
const MCP_TOOL_NAMES = (() => {
|
||||
try {
|
||||
const src = fs.readFileSync(new URL('../mcp/server.mjs', import.meta.url), 'utf8');
|
||||
const start = src.indexOf('const TOOLS = [');
|
||||
const end = src.indexOf('\n];', start);
|
||||
if (start < 0 || end < 0) return [];
|
||||
return [...src.slice(start, end).matchAll(/name:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
const OPENAPI_DESCRIPTION = `JSON consommé par le front NewTube et par des clients externes (scripts,
|
||||
Postman, collecteurs). **Toutes les routes ci-dessous sont préfixées par le serveur choisi** : \`/api\` (direct)
|
||||
ou \`/proxy/api\` (miroir utilisé par le front en production) — même contrats des deux côtés.
|
||||
|
||||
## Authentification
|
||||
Trois mécanismes interchangeables sur les routes protégées (bouton **Authorize**) :
|
||||
|
||||
1. **JWT (navigateur)** — \`POST /auth/login\` renvoie \`accessToken\` (15 min) :
|
||||
\`Authorization: Bearer <accessToken>\`.
|
||||
2. **Clé d'API (clients externes)** — \`POST /keys\` (avec un Bearer) renvoie \`ntk_…\` **une seule fois** :
|
||||
\`X-API-Key: ntk_…\`. Empreinte SHA-256 en base, révocation immédiate par \`DELETE /keys/{id}\`,
|
||||
dernier usage horodaté. Recommandé pour tout script longue durée.
|
||||
3. **Cookies httpOnly (le front)** — \`sid\` + \`refreshToken\` posés par \`/auth/login\`, renouvelés par
|
||||
\`POST /auth/refresh\`. Un client script n'a pas besoin de les imiter : utiliser (1) ou (2).
|
||||
|
||||
## Erreurs et limites de débit
|
||||
Corps d'erreur homogène : \`{ "error": "<code>" }\` — 400 saisie, 401 non authentifié, 403 interdit,
|
||||
404 introuvable, 429 \`rate_limited\` (headers \`RateLimit-*\`), 503 fournisseur indisponible
|
||||
(ex. \`rumble_cloudflare_challenge\`). Seaux notables : login 5/min, \`/details\` 60/min,
|
||||
\`/transcript\` 10/min. Chaque réponse porte \`X-Request-Id\` (à citer dans un rapport de bug) et
|
||||
\`GET /metrics\` expose les compteurs Prometheus.
|
||||
|
||||
## Serveur MCP
|
||||
Un serveur MCP en stdio (\`mcp/server.mjs\`, \`npm run mcp\`) expose NewTube aux clients type
|
||||
Claude / VS Code / opencode :${MCP_TOOL_NAMES.length ? `\n\n\`${MCP_TOOL_NAMES.join('` `')}\`` : ''}
|
||||
|
||||
\`\`\`json
|
||||
{ "mcpServers": { "newtube": {
|
||||
"command": "node",
|
||||
"args": ["…/NewTube/mcp/server.mjs"],
|
||||
"env": {
|
||||
"NEWTUBE_API_URL": "http://localhost:4000/api",
|
||||
"NEWTUBE_TOKEN": "<accessToken obtenu via POST /auth/login>"
|
||||
} } } }
|
||||
\`\`\`
|
||||
|
||||
\`NEWTUBE_TOKEN\` n'est requis que pour les outils d'écriture (playlists, likes, abonnements,
|
||||
historique, téléchargements) ; la lecture publique fonctionne sans jeton. Guide détaillé :
|
||||
\`docs/API_MCP_GUIDE.md\`.`;
|
||||
|
||||
// --- OpenAPI (référence machine : lecture publique + espace utilisateur) ---
|
||||
// Version = package.json, source unique partagée avec le front (src/app/version.ts).
|
||||
const OPENAPI_VERSION = (() => {
|
||||
@@ -4207,10 +4461,11 @@ r.get('/openapi.json', (_req, res) => {
|
||||
const bearer = [{ bearerAuth: [] }, { apiKeyAuth: [] }];
|
||||
const doc = {
|
||||
openapi: '3.0.0',
|
||||
servers: [{ url: '/api' }, { url: '/proxy/api' }],
|
||||
info: {
|
||||
title: 'NewTube API',
|
||||
version: OPENAPI_VERSION,
|
||||
description: "Agrégateur vidéo multi-fournisseurs. Authentification des routes protégées : soit `Authorization: Bearer <accessToken>` (obtenu sur /auth/login), soit `X-API-Key: ntk_…` (clé créée sur /keys, révocable).",
|
||||
description: OPENAPI_DESCRIPTION,
|
||||
},
|
||||
paths: {
|
||||
'/healthz': {
|
||||
@@ -4323,9 +4578,40 @@ r.get('/openapi.json', (_req, res) => {
|
||||
}
|
||||
},
|
||||
components: { securitySchemes: {
|
||||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
|
||||
} }
|
||||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'accessToken renvoyé par POST /auth/login (15 min).' },
|
||||
apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key', description: 'Clé ntk_… créée par POST /keys, affichée une seule fois, révocable via DELETE /keys/{id}.' },
|
||||
cookieAuth: { type: 'apiKey', in: 'cookie', name: 'refreshToken', description: 'Cookies httpOnly (sid + refreshToken) posés par le front — usage navigateur uniquement.' },
|
||||
} },
|
||||
tags: TAG_DESCRIPTIONS,
|
||||
};
|
||||
|
||||
// ---- Inventaire Express : complète le document --------------------------
|
||||
// Tout ce qui n'est pas décrit à la main ci-dessus (paramètres, réponses)
|
||||
// est ajouté depuis l'arbre Express : le document ne peut pas dériver du code.
|
||||
const documented = new Set(Object.entries(doc.paths)
|
||||
.flatMap(([p, ops]) => Object.keys(ops).map((m) => `${m.toUpperCase()} ${p}`)));
|
||||
const tagOf = (p) => {
|
||||
const seg = String(p).split('/')[1] || 'meta';
|
||||
return TAG_OF[seg] || seg;
|
||||
};
|
||||
for (const route of collectApiRoutes((app._router || app.router || {}).stack || [], '', [])) {
|
||||
const key = `${route.method} ${route.path}`;
|
||||
if (documented.has(key)) continue;
|
||||
documented.add(key);
|
||||
const op = { tags: [tagOf(route.path)] };
|
||||
const summary = API_SUMMARIES[key];
|
||||
if (summary) op.summary = summary;
|
||||
if (route.auth) op.security = bearer;
|
||||
if (!doc.paths[route.path]) doc.paths[route.path] = {};
|
||||
doc.paths[route.path][route.method.toLowerCase()] = op;
|
||||
}
|
||||
|
||||
// Les entrées détaillées écrites à la main ne portent pas de tag : on complète,
|
||||
// sinon Swagger les groupe sous « default ».
|
||||
for (const [p, ops] of Object.entries(doc.paths)) {
|
||||
const t = tagOf(p);
|
||||
for (const op of Object.values(ops)) if (!Array.isArray(op.tags) || !op.tags.length) op.tags = [t];
|
||||
}
|
||||
|
||||
res.json(doc);
|
||||
});
|
||||
|
||||
@@ -310,3 +310,59 @@ describe('production-ready (P0/P1)', () => {
|
||||
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`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user