// 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('index.html et index.css : jamais mis en cache (seuls fichiers non fingerprintés)', async () => { for (const p of ['/', '/index.css']) { const r = await fetch(`${base}${p}`); assert.equal(r.status, 200, p); const cc = r.headers.get('cache-control') || ''; // Sans revalidation, F5 continue de servir l'ANCIEN fichier jusqu'à 1 h // après un redéploiement : le correctif est bien sur le serveur mais // invisible au navigateur. assert.match(cc, /no-cache/, `${p} doit être revalidé à chaque chargement (reçu : ${cc || 'vide'})`); assert.ok(!/max-age=[1-9]/.test(cc), `${p} ne doit pas porter de max-age positif (${cc})`); } const css = await fetch(`${base}/index.css`); assert.match(css.headers.get('content-type') || '', /css/, '/index.css doit servir du CSS'); }); 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`); }); });