248 lines
16 KiB
JavaScript
248 lines
16 KiB
JavaScript
// 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); });
|