/** * Phase 7.3 — mode debug `?debug=1` sur `/api/search`. * * Ce mode est une barrière de sécurité autant qu'un outil : un `raw` de provider * peut contenir une clé d'API, et un payload de debug finit souvent dans les * logs de CI. Les tests ci-dessous prouvent donc que les garde-fous TIENNENT : * troncature à 600 caractères, 2 items par provider, redaction des secrets * (y compris imbriqués dans des tableaux), et aucune fuite quand le `raw` est * circulaire ou non sérialisable. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildDebugPayload, redactDebugSecrets, DEBUG_RAW_MAX_CHARS } from '../search-transport.mjs'; const AT = 1700000000000; test('7.3 — le payload reste borné (2 items/provider) et compte le total', () => { const items = Array.from({ length: 7 }, (_, i) => ({ id: `i${i}`, title: `t${i}`, source: 'api', capturedAt: AT })); const dbg = buildDebugPayload({ yt: items }, {}); assert.equal(dbg.yt.count, 7, 'le total reste visible même tronqué'); assert.equal(dbg.yt.items.length, 2); assert.equal(dbg.yt.capturedAt, AT, 'provenance du provider agrégée'); assert.equal(dbg.yt.source, 'api'); }); test('7.3 — les champs mappés sont nommés (pas seulement comptés)', () => { const dbg = buildDebugPayload({ dm: [{ id: 'x', title: 'T', views: 12, capturedAt: AT, source: 'api', channelRef: { provider: 'dm' } }] }, {}); const fields = dbg.dm.items[0].fields; assert.deepEqual(fields, ['channelRef', 'title', 'views'], 'noms triés, hors provenance'); assert.equal(dbg.dm.items[0].id, 'x'); assert.equal(dbg.dm.items[0].capturedAt, AT); }); test('7.3 — un champ absent reste absent dans le debug', () => { // `views` n'est pas là : c'est l'information à ne pas maquiller en `null`. const dbg = buildDebugPayload({ tw: [{ id: 'a', title: 'T', source: 'api', capturedAt: AT }] }, {}); assert.equal(dbg.tw.items[0].fields.includes('views'), false); assert.equal(dbg.tw.items[0].raw, undefined, 'pas de `raw` => pas de champ `raw` dans le payload'); }); test('7.3 — le `raw` est tronqué à 600 caractères', () => { const raw = { payload: 'x'.repeat(5000) }; const dbg = buildDebugPayload({ pt: [{ id: 'p', source: 'api', capturedAt: AT, raw }] }, {}); const shown = dbg.pt.items[0].raw; assert.ok(shown.startsWith('{"payload":"xxx'), 'le début du JSON est visible'); assert.ok(shown.includes('… ('), 'la troncature est signalée'); assert.ok(shown.includes('car.)'), 'la taille réelle est annoncée'); // 600 caractères de JSON + la mention de troncature. assert.ok(shown.length < DEBUG_RAW_MAX_CHARS + 60, 'la sortie reste courte'); assert.ok(shown.length > DEBUG_RAW_MAX_CHARS, 'la troncature a bien eu lieu'); }); test('7.3 — un `raw` court est rendu tel quel', () => { const dbg = buildDebugPayload({ od: [{ id: 'o', source: 'api', capturedAt: AT, raw: { title: 'T' } }] }, {}); assert.equal(dbg.od.items[0].raw, '{"title":"T"}'); }); test('7.3 — redaction des secrets, y compris imbriqués', () => { const redacted = redactDebugSecrets({ apiKey: 'AKIA-REAL', api_key: 'x', authorization: 'Bearer REAL', token: 'REAL', cookie: 'sid=REAL', password: 'REAL', clientSecret: 'REAL', nested: { accessToken: 'REAL', keep: 'visible', list: [{ refresh_token: 'REAL' }] }, // Une clé qui CONTIENT « token » mais n'est pas un secret doit survivre. tokenCount: 3, }); const json = JSON.stringify(redacted); assert.equal(json.includes('REAL'), false, `aucun secret ne doit subsister : ${json}`); assert.equal(redacted.nested.keep, 'visible', 'les données utiles sont conservées'); assert.equal(redacted.tokenCount, 3, 'une clé non sensible n\'est pas censurée à tort'); assert.equal(redacted.list, undefined); assert.equal(redacted.nested.list[0].refresh_token, '[redacted]', 'redaction dans un tableau imbriqué'); }); test('7.3 — la redaction traverse le `raw` avant sérialisation', () => { const dbg = buildDebugPayload({ ru: [{ id: 'r', source: 'api', capturedAt: AT, raw: { authorization: 'Bearer LEAK', ok: 1 } }] }, {}); assert.equal(dbg.ru.items[0].raw.includes('LEAK'), false, 'le secret ne doit pas apparaître dans le JSON renvoyé'); assert.ok(dbg.ru.items[0].raw.includes('[redacted]')); }); test('7.3 — un `raw` circulaire ne fait pas exploser le diagnostic', () => { const raw = { name: 'loop' }; raw.self = raw; const dbg = buildDebugPayload({ yt: [{ id: 'y', source: 'api', capturedAt: AT, raw }] }, {}); assert.equal(dbg.yt.items[0].raw, '[unserialisable]', 'on degrade au lieu de lever'); }); test('7.3 — erreurs de provider remontées, sans division par zéro', () => { const dbg = buildDebugPayload( { yt: [], dm: [{ id: 'a', source: 'api', capturedAt: AT }] }, { yt: { message: 'HTTP 503 innertube' } }, ); assert.equal(dbg.yt.error, 'HTTP 503 innertube', 'le message d\'erreur aide au diagnostic'); assert.equal(dbg.yt.capturedAt, null, 'aucun item => provenance inconnue'); assert.equal(dbg.dm.error, null); }); test('7.3 — entrées dégénérées tolérées', () => { const dbg = buildDebugPayload({ yt: null, dm: [null, 'texte', 42] }, null); assert.equal(dbg.yt.count, 0); assert.deepEqual(dbg.yt.items, []); assert.equal(dbg.dm.items[0].value, null); assert.equal(dbg.dm.items[1].value, 'texte'); assert.equal(dbg.dm.count, 3, 'le total reste malgré la troncature à 2'); assert.deepEqual(buildDebugPayload(undefined, undefined), {}); });