/** * Phase 7.7 — bannières et descriptions de chaîne, les 6 fournisseurs. * * Aucun réseau : `globalThis.fetch` est stubé. On vérifie que * - chaque fournisseur expose `bannerUrl` et `description` quand la source les * donne (aucun appel réseau supplémentaire — YT compte ses appels), * - les URL non-http(s) sont rejetées (`javascript:` = vecteur XSS), * - la description est bornée (600 caractères, whitespace normalisé), * - les chaînes sans bannière restent `undefined` (on n'invente rien). */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; process.env.YOUTUBE_API_KEY = 'fake-key'; process.env.TWITCH_CLIENT_ID = 'fake-client'; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-banners-')); process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'banners.db'); const { channelRegistry, setTwitchTokenProvider } = await import('../providers/channel-registry.mjs'); setTwitchTokenProvider(async () => 'fake-token'); const db = await import('../db.mjs'); const calls = []; function fakeResponse(body, { text = false } = {}) { return { ok: true, status: 200, json: async () => body, text: async () => (text ? body : JSON.stringify(body)), }; } function installFetch(routes) { const prev = globalThis.fetch; calls.length = 0; globalThis.fetch = async (url, opts) => { calls.push({ url: String(url), opts }); const match = routes.find(r => r.test(String(url))); if (!match) throw new Error(`network_not_stubbed: ${url}`); return match.respond(opts); }; return () => { globalThis.fetch = prev; }; } test('7.7 — YouTube : bannière et description depuis le payload existing', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), respond: () => fakeResponse({ items: [{ snippet: { title: 'YT User', description: ' la belle description ', thumbnails: { high: { url: 'https://img/avatar.png' } }, }, statistics: { subscriberCount: '42' }, brandingSettings: { image: { bannerImageUrl: 'https://img/banner.png' }, channel: { customUrl: 'ytuser' }, }, }], }), }]); t.after(restore); const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); assert.equal(meta.bannerUrl, 'https://img/banner.png'); assert.equal(meta.description, 'la belle description'); // La bannière ne nécessite aucun appel de plus : un seul /channels. assert.equal(calls.filter(c => c.url.includes('googleapis.com')).length, 1); }); test('7.7 — YouTube : une URL non-http(s) est rejetée, pas propagee', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), respond: () => fakeResponse({ items: [{ snippet: { title: 'X', thumbnails: {} }, brandingSettings: { image: { bannerImageUrl: 'javascript:alert(1)' } }, }], }), }]); t.after(restore); const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); assert.equal(meta.bannerUrl, undefined, 'javascript: ne doit jamais ressortir'); assert.equal(meta.description, undefined); }); test('7.7 — YouTube : description démesurée bornée à 600 caractères', async (t) => { const huge = 'x'.repeat(5000); const restore = installFetch([{ test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'), respond: () => fakeResponse({ items: [{ snippet: { title: 'X', description: huge, thumbnails: {} }, brandingSettings: {}, }], }), }]); t.after(restore); const meta = await channelRegistry.yt.fetchChannelById('UCxxxx'); assert.ok(meta.description.endsWith('…'), 'tronquée avec ellipse'); assert.equal(meta.description.length, 600); }); test('7.7 — Dailymotion : cover_url + description', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://api.dailymotion.com/user/'), respond: () => fakeResponse({ username: 'dmuser', screenname: 'DM User', avatar_720_url: 'https://img/avatar.png', cover_url: 'https://img/banner.png', description: 'desc dm', url: 'https://www.dailymotion.com/user/dmuser', followers_total: 7, verified: false, }), }]); t.after(restore); const meta = await channelRegistry.dm.fetchChannelById('dmuser'); assert.equal(meta.bannerUrl, 'https://img/banner.png'); assert.equal(meta.description, 'desc dm'); }); test('7.7 — Twitch : banner_image_url + description d\'Helix', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://api.twitch.tv/helix/users?'), respond: () => fakeResponse({ data: [{ display_name: 'TW User', login: 'twuser', profile_image_url: 'https://img/avatar.png', banner_image_url: 'https://img/banner.png', description: 'desc tw', view_count: 12, }], }), }]); t.after(restore); const meta = await channelRegistry.tw.fetchChannelById('twuser'); assert.equal(meta.bannerUrl, 'https://img/banner.png'); assert.equal(meta.description, 'desc tw'); assert.equal(calls[0].opts.headers['Authorization'], 'Bearer fake-token'); }); test('7.7 — Twitch : sans bannière Helix, rien d\'inventé', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://api.twitch.tv/helix/users?'), respond: () => fakeResponse({ data: [{ display_name: 'TW', login: 'twuser', profile_image_url: 'https://img/a.png' }] }), }]); t.after(restore); const meta = await channelRegistry.tw.fetchChannelById('twuser'); assert.equal(meta.bannerUrl, undefined); assert.equal(meta.description, undefined); }); test('7.7 — PeerTube : banners (plus grande) + description', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'), respond: () => fakeResponse({ displayName: 'PT User', name: 'chan', host: 'peertube.example', avatar: { path: '/a.png' }, banners: [{ path: '/b1.png' }, { path: '/banner.png' }], description: 'desc pt', url: 'https://peertube.example/video-channels/chan', followersCount: 3, ownerAccount: { verified: true }, }), }]); t.after(restore); const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan'); assert.equal(meta.bannerUrl, 'https://peertube.example/banner.png', 'la plus grande bannière'); assert.equal(meta.description, 'desc pt'); }); test('7.7 — PeerTube : repli sur `banner` si `banners` est vide', async (t) => { const restore = installFetch([{ test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'), respond: () => fakeResponse({ displayName: 'PT User', name: 'chan', host: 'peertube.example', avatar: { path: '/a.png' }, banners: [], banner: { path: '/old.png' }, }), }]); t.after(restore); const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan'); assert.equal(meta.bannerUrl, 'https://peertube.example/old.png'); }); test('7.7 — Odysee : vignette élargie + description (pas de bannière LBRY)', async (t) => { const restore = installFetch([{ test: u => u === 'https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', respond: () => fakeResponse({ result: { '@chan': { short_url: 'https://odysee.com/@chan', thumbnail: { url: 'https://cdn.odysee.com/thumb' }, meta: { effective_amount: 1 }, value: { title: 'OD User', description: 'desc od', thumbnail: { url: 'https://cdn.odysee.com/thumb' }, }, }, }, }), }]); t.after(restore); const meta = await channelRegistry.od.fetchChannelById('@chan'); assert.equal(meta.bannerUrl, 'https://cdn.odysee.com/thumb?size=1200x600'); assert.equal(meta.description, 'desc od'); assert.equal(calls[0].opts.method, 'POST'); }); test('7.7 — Rumble : og:description (attributs dans any order) sans appel de plus', async (t) => { const restore = installFetch([{ test: u => u === 'https://rumble.com/ruuser', respond: () => fakeResponse( '' + '' + '