Files
chopin/tests/dev/admin-youtube.test.js

248 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Routes "/ADMIN/YOUTUBE/*" de backend/src/server/Admin.js, appelées par un faux socket.
// Dépendances lourdes (bot Discord, utilisateurs, players, bases...) simulées ; données YouTube isolées
// dans le scratchpad (jamais backend/data). Aucun appel réseau : yt-dlp (run/update/check/test) est simulé.
const Module = require('module');
const path = require('path');
const fs = require('fs');
const { BACKEND, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('admin-yt');
process.env.YTDLP_AUTO_UPDATE = 'false';
process.env.YTDLP_COOKIES_ON_PROXY = 'false'; // verrouillé par l'environnement
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
const stubs = {
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : null, getUsers: () => [admin] },
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => null, getMediaChannelId: () => null },
[norm(SRC + '/utils/Database/Database')]: { getDatabase: () => null },
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map([['222222', { pid: 1 }]]) },
};
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const Admin = req('src/server/Admin.js');
const youtube = req('src/youtube');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Cookies SYNTHÉTIQUES (valeurs factices), domaine YouTube + un domaine étranger
const SECRET = 'FAKESECRET';
const exp = Math.floor(Date.now() / 1000) + 30 * 86400;
const line = (d, n, v) => [d, 'TRUE', '/', 'TRUE', String(exp), n, v].join('\t');
const COOKIES_OK = ['# Netscape HTTP Cookie File',
line('.youtube.com', 'LOGIN_INFO', SECRET + '_login'),
line('.youtube.com', 'SAPISID', SECRET + '_sapisid'),
line('.youtube.com', '__Secure-3PAPISID', SECRET + '_3p'),
line('.youtube.com', 'SID', SECRET + '_sid'),
line('.example.org', 'tracker', SECRET + '_other')].join('\r\n');
const COOKIES_ANON = ['# Netscape HTTP Cookie File', line('.youtube.com', 'YSC', SECRET + '_ysc')].join('\n');
(async () => {
const expectedRoutes = ['STATUS', 'COOKIES/IMPORT', 'COOKIES/BROWSER', 'COOKIES/CHECK', 'COOKIES/DELETE', 'SETTINGS', 'TEST', 'UPDATE', 'DIAGNOSTICS/CLEAR'];
check('routes enregistrées', expectedRoutes.every(r => handlers.has('/ADMIN/YOUTUBE/' + r)));
// Vérification en direct simulée (pas de réseau)
let checkCalls = 0;
youtube.check.checkCookies = async () => {
checkCalls++;
const result = { at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'Compte reconnu', cls: null, route: 'direct' };
youtube.cookies.setLastCheck(result);
return { ...result, rotated: false, ms: 5, status: youtube.cookies.getStatus() };
};
console.log('# STATUS');
let r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('STATUS ok', r.ok, r);
check('STATUS forme', r.data && r.data.ytdlp && r.data.cookies && r.data.settings && r.data.diagnostics && Array.isArray(r.data.recent));
check('aucun cookie au départ', r.data.cookies.present === false);
check('cookiesOnProxy verrouillé', r.data.settings.cookiesOnProxy.locked === true);
console.log('# IMPORT');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: '' });
check('contenu vide refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'x'.repeat(1000001) });
check('contenu > 1 000 000 refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'browser:firefox' });
check('source interdite refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'du texte qui ne ressemble à rien' });
check('contenu illisible -> INVALID_PARAMS avec message FR', !r.ok && r.error === 'INVALID_PARAMS' && /cookie/i.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_ANON, source: 'paste' });
check('import sans compte : ok + avertissement', r.ok && r.data.warnings.some(w => /connexion/i.test(w)) && r.data.checking === false, r.data?.warnings);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'file' });
check('import connecté ok', r.ok && r.data.status.cookies.loggedIn && r.data.status.cookies.valid, r);
check('avertissement domaine étranger', r.ok && r.data.warnings.some(w => /autres sites/.test(w)), r.data?.warnings);
check('vérification lancée', r.data.checking === true);
check('source / auteur enregistrés', r.data.status.cookies.source === 'file' && r.data.status.cookies.importedBy?.username === 'testadmin');
check('aucune valeur de cookie dans la réponse', !JSON.stringify(r).includes(SECRET));
check('fichier maître dans le dossier isolé', fs.existsSync(path.join(dataDir, 'cookies.txt')));
await sleep(50);
check('checkCookies appelé', checkCalls === 1);
console.log('# Poussée');
emitted.length = 0;
await sleep(700);
const push = emitted.filter(e => e.ev === '/ADMIN/YOUTUBE/UPDATE');
check('poussée /ADMIN/YOUTUBE/UPDATE (coalescée)', push.length === 1 && push[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée contient lastCheck', push[0]?.data?.cookies?.lastCheck?.ok === true);
rooms.set('ADMIN', new Set());
emitted.length = 0;
process.emit('YOUTUBE_UPDATE');
await sleep(700);
check('pas de poussée si room ADMIN vide', emitted.length === 0);
rooms.set('ADMIN', new Set(['sock1']));
console.log('# CHECK');
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK ok', r.ok && r.data.result.ok === true && r.data.result.status === undefined && r.data.status.cookies.lastCheck, r);
console.log('# SETTINGS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'always', playerClients: 'web_safari, tv' } });
check('SETTINGS appliqués', r.ok && r.data.settings.cookiesMode.value === 'always' && r.data.settings.playerClients.value === 'web_safari,tv', r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesOnProxy: true } });
check('clé verrouillée refusée', !r.ok && r.error === 'INVALID_PARAMS' && /YTDLP_COOKIES_ON_PROXY/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { seekStrategy: 'n importe quoi' } });
check('valeur invalide refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { inconnu: 1 } });
check('clé inconnue refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: {} });
check('patch vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: [1] });
check('patch tableau refusé', !r.ok && r.error === 'INVALID_PARAMS');
// Retour aux valeurs par défaut
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'fallback', playerClients: '' } });
check('SETTINGS remis par défaut', r.ok && r.data.settings.cookiesMode.value === 'fallback' && r.data.settings.playerClients.value === '');
check('settings.json dans le dossier isolé', fs.existsSync(path.join(dataDir, 'settings.json')));
console.log('# TEST');
let testArgs = null;
const realTest = youtube.check.testPlayback;
youtube.check.testPlayback = async (opts) => { testArgs = opts; return { ok: true, route: 'direct', identity: 'anon', clients: '', cls: null, message: 'Extraction réussie', format: { id: '251' }, ms: 1, version: 'x' }; };
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'direct', identity: 'anon', url: '', clients: '' });
check('TEST transmis (url / clients vides -> réglages)', r.ok && testArgs.route === 'direct' && testArgs.url === undefined && testArgs.clients === undefined, testArgs);
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'moon' });
check('route invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { identity: 'root' });
check('identité invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { url: 'javascript:alert(1)' });
check('url non http refusée', !r.ok && r.error === 'INVALID_PARAMS');
youtube.check.testPlayback = async () => { const e = new Error('Un test de lecture est déjà en cours'); e.code = 'BUSY'; throw e; };
r = await call('/ADMIN/YOUTUBE/TEST', {});
check('BUSY -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
youtube.check.testPlayback = realTest;
// Vraie fonction, sans réseau : clients invalides -> INVALID_PARAMS
r = await call('/ADMIN/YOUTUBE/TEST', { clients: 'web;rm' });
check('clients invalides (vrai testPlayback) -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
console.log('# UPDATE');
youtube.ytdlp.update = async ({ reason }) => ({ ok: true, updatable: true, before: '1', after: '2', message: 'Mise à jour : 1 → 2', output: '', at: new Date().toISOString(), reason });
r = await call('/ADMIN/YOUTUBE/UPDATE', {});
check('UPDATE ok', r.ok && r.data.result.after === '2' && /testadmin/.test(r.data.result.reason) && r.data.status.ytdlp, r);
console.log('# BROWSER (yt-dlp simulé)');
const realRun = youtube.ytdlp.run;
let runArgs = null;
youtube.ytdlp.run = async (args) => {
runArgs = args;
const file = args[args.indexOf('--cookies') + 1];
fs.writeFileSync(file, COOKIES_OK + '\n' + line('.bank.example', 'session', SECRET + '_bank'));
return { code: 1, stdout: '', stderr: 'ERROR: [youtube] x: Sign in to confirm you’re not a bot', timedOut: false };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'default-release' });
check('BROWSER importe malgré l\'échec d\'extraction', r.ok && r.data.status.cookies.source === 'browser:firefox' && r.data.status.cookies.loggedIn, r);
check('arguments --cookies-from-browser', runArgs && runArgs[runArgs.indexOf('--cookies-from-browser') + 1] === 'firefox:default-release' && runArgs.includes('--skip-download'));
const tmpDir = path.dirname(runArgs[runArgs.indexOf('--cookies') + 1]);
check('fichier temporaire du navigateur supprimé', !fs.existsSync(tmpDir), tmpDir);
check('cookies étrangers non stockés', !fs.readFileSync(path.join(dataDir, 'cookies.txt'), 'utf-8').includes('bank'));
youtube.ytdlp.run = async () => ({ code: 1, stdout: '', stderr: 'ERROR: could not find firefox cookies database in /x --cookies /secret/path', timedOut: false });
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox' });
check('échec navigateur -> UNAVAILABLE explicite', !r.ok && r.error === 'UNAVAILABLE' && /même machine/.test(r.message) && !r.message.includes('/secret/path'), r);
youtube.ytdlp.run = async (args) => {
fs.writeFileSync(args[args.indexOf('--cookies') + 1], '# Netscape HTTP Cookie File\n' + line('.bank.example', 'session', 'x'));
return { code: 0, stdout: '', stderr: '' };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'chrome' });
check('navigateur sans cookie YouTube -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /chrome/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'netscape' });
check('navigateur inconnu refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'a::b' });
check('profil invalide refusé', !r.ok && r.error === 'INVALID_PARAMS');
youtube.ytdlp.run = realRun;
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# OVERVIEW');
r = await call('/ADMIN/OVERVIEW', {});
const yt = r.data?.ytdlp;
check('overview.ytdlp compatible', r.ok && yt && 'version' in yt && yt.cookies.present === true && yt.cookies.updatedAt === yt.cookies.importedAt && yt.activeProcesses === 1, r.ok ? yt : r);
check('overview.ytdlp enrichi', yt && yt.cookies.valid === true && 'recentFailureRate' in yt && 'lastFailure' in yt);
console.log('# DIAGNOSTICS');
youtube.diagnostics.record({ guildId: '222222', songId: 's', title: 'Titre', route: 'direct', identity: 'anon', outcome: 'failed', cls: 'HTTP_403', message: 'HTTP Error 403', ms: 100 });
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('recent avec nom du serveur', r.data.recent[0]?.guildName === 'Serveur Test' && r.data.diagnostics.byClass.HTTP_403 === 1, r.data.recent[0]);
r = await call('/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR', {});
check('CLEAR', r.ok && r.data.recent.length === 0 && r.data.diagnostics.total === 0);
console.log('# DELETE');
r = await call('/ADMIN/YOUTUBE/COOKIES/DELETE', {});
check('DELETE', r.ok && r.data.cookies.present === false && !fs.existsSync(path.join(dataDir, 'cookies.txt')));
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK sans cookies -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE');
console.log('# Droits');
admin.labels = []; admin.isAdmin = () => false;
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('non-admin refusé', !r.ok && r.error === 'FORBIDDEN');
admin.isAdmin = () => true; admin.labels = ['ADMIN'];
// Journaux : aucune valeur de cookie
await sleep(300);
const logs = path.join(__dirname, 'logs');
let leaked = false;
for (const f of fs.readdirSync(logs)) if (fs.readFileSync(path.join(logs, f), 'utf-8').includes(SECRET)) leaked = f;
check('aucune valeur de cookie dans les logs', !leaked, leaked);
const auditLines = fs.readdirSync(logs).flatMap(f => fs.readFileSync(path.join(logs, f), 'utf-8').split('\n')).filter(l => l.includes('[ADMIN] testadmin -> ') && /YouTube|yt-dlp/.test(l));
check('audits écrits', auditLines.length >= 6, auditLines.length);
console.log(auditLines.slice(-12).map(l => ' ' + l.trim()).join('\n'));
done();
setTimeout(() => process.exit(process.exitCode), 100);
})().catch((e) => { console.error(e); process.exit(1); });