Files
NewTube/server/tests/api_coverage.test.mjs
T
bruno 41a28dfccc
CI / build-and-test (push) Successful in 14m57s
feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
P0
- Aucun secret servi au navigateur : /assets/config.local.js est généré par le
  serveur AVANT les montages statiques (il l'emporte sur le fichier local) et
  ne contient plus YOUTUBE_API_KEY(S) ; le fichier local n'est plus copié dans
  l'image Docker ni embarqué dans dist (angular ignore config.local.js) ;
  youtube-api.service n'appelle plus googleapis directement (fetchYouTube =
  /api/yt -> /proxy/api/yt), gardes « pas de clé => écran vide », rotation et
  carte de bans côté client supprimées ; readiness YouTube via /healthz
  (youtube.keys.count) ; messages d'erreur orientés configuration serveur.
- CORS : origines pilotées par API_ALLOWED_ORIGINS (CSV), méthode PATCH
  ajoutée (requis par /user/preferences), header X-API-Key accepté.
- Clés d'API longue durée : table api_keys (empreinte SHA-256 + préfixe
  affichable), routes GET/POST /api/keys et DELETE /api/keys/:id, jeton
  ntk_… affiché une seule fois, last_used_at à chaque usage ; X-API-Key
  accepté par authMiddleware ET authMiddlewareCookieAware.

P1
- X-Request-Id renvoyé sur chaque réponse + journal JSON structuré en prod
  (ts, reqId, method, route, status, ms).
- GET /metrics : exposition Prometheus sans dépendance (http_requests_total
  par route/code, somme+nombre de durées, uptime/mémoire ; METRICS_TOKEN
  verrouille l'accès si défini).
- Rate-limit sur /api/details (DETAILS_RATE_LIMIT, 60/min, réponse JSON) —
  /transcript avait déjà le sien.
- Version unique package.json : menu du compte, info.version de l'OpenAPI
  (+ schéma apiKeyAuth et chemins /keys / /metrics documentés).

Tests : api_coverage +7 cas « production-ready » (sentinel de fuite de clé,
X-Request-Id, /metrics, CORS PATCH, contrat/version, cycle complet des clés
d'API), suggest, transcript, flags, filters, kind — verts. Build OK.
Vérifs instance locale : config servie sans secret, /api/yt 200 avec la clé
serveur, /metrics alimenté, menu 1.0.59 et 40 cartes rendues sans clé côté
client.
2026-10-02 23:24:50 -04:00

313 lines
17 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);
});
});