feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s

7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* 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), {});
});