feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
CI / build-and-test (push) Successful in 14m57s

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.
This commit is contained in:
2026-10-02 23:24:50 -04:00
parent 6add43e730
commit 41a28dfccc
16 changed files with 449 additions and 237 deletions
+94 -1
View File
@@ -23,7 +23,10 @@ const PORT = await new Promise((resolve) => {
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' },
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 = '';
@@ -217,3 +220,93 @@ describe('auth + espace utilisateur', () => {
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);
});
});