357 lines
23 KiB
JavaScript
357 lines
23 KiB
JavaScript
// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes)
|
|
// de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les
|
|
// vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier
|
|
// temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé).
|
|
const Module = require('module');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup');
|
|
|
|
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
|
|
const DATA_DIR = path.join(BACKEND, 'data');
|
|
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-'));
|
|
for (const [key, value] of Object.entries(__glob)) {
|
|
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
|
|
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
|
|
}
|
|
}
|
|
__glob.DATA = tmp;
|
|
isolateData('admin-config-yt');
|
|
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
|
|
if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); }
|
|
process.env.YTDLP_AUTO_UPDATE = 'false';
|
|
delete process.env.WARP_PROXY;
|
|
delete process.env.WARP_FALLBACK_DIRECT;
|
|
|
|
// Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log
|
|
const SECRETS = {
|
|
token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123',
|
|
client_secret: 'FAKECLIENTSECRET_9876543210',
|
|
youtube: 'FAKEYTSECRET_aaaabbbbcccc',
|
|
spotify: 'FAKESPOTIFYSECRET_ddddeeee',
|
|
refresh: 'FAKEREFRESHTOKEN_ffffgggg',
|
|
proxyPass: 'FAKEPROXYPASS_hhhh'
|
|
};
|
|
fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({
|
|
token: SECRETS.token,
|
|
client_secret: SECRETS.client_secret,
|
|
report: { channel: '123456789012345678', contact: '' },
|
|
api: {
|
|
youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube },
|
|
spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify }
|
|
},
|
|
website: 'https://chopin.example.org',
|
|
server_port: 5000,
|
|
media: { guildId: '', channelId: '' }
|
|
}, null, 2));
|
|
|
|
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 owner = {
|
|
identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [],
|
|
isAdmin: () => false, isFullBanned: () => false
|
|
};
|
|
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
|
|
|
|
// Player simulé : loop / shuffle comptés pour vérifier l'idempotence
|
|
const playerCalls = { setLoop: 0, setShuffle: 0 };
|
|
const fakePlayer = {
|
|
guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} },
|
|
isConnected: () => true,
|
|
async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; },
|
|
async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; },
|
|
getState() {
|
|
return {
|
|
guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle,
|
|
current: { id: 'song-b', title: 'B' },
|
|
lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() },
|
|
next: [], previous: []
|
|
};
|
|
}
|
|
};
|
|
const AllPlayers = new Map([['222222', fakePlayer]]);
|
|
const removed = [];
|
|
|
|
const stubs = {
|
|
[norm(SRC + '/server/auth/User')]: {
|
|
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
|
|
getUsers: () => [admin, owner]
|
|
},
|
|
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
|
|
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers },
|
|
[norm(SRC + '/discord/Bot')]: {
|
|
getGuilds: () => guilds, getClient: () => null, isReady: () => false,
|
|
getChannel: () => null
|
|
},
|
|
[norm(SRC + '/discord/ServerSettings')]: {},
|
|
[norm(SRC + '/discord/MediaBase')]: {},
|
|
[norm(SRC + '/playlists/PlaylistManager')]: {
|
|
removePlaylist(userId, playlistId) {
|
|
const list = getDatabase('Playlists').data[userId] || [];
|
|
const index = list.findIndex(p => String(p.playlistId) === String(playlistId));
|
|
if (index === -1) return false;
|
|
list.splice(index, 1);
|
|
removed.push({ userId, playlistId });
|
|
return true;
|
|
}
|
|
},
|
|
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
|
|
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
|
|
[norm(SRC + '/utils/Maintenance')]: {},
|
|
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
|
|
};
|
|
|
|
// Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué)
|
|
let spotifyMode = 'reject';
|
|
class FakeSpotify {
|
|
constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; }
|
|
async clientCredentialsGrant() {
|
|
if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } };
|
|
throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } });
|
|
}
|
|
}
|
|
|
|
const origLoad = Module._load;
|
|
Module._load = function (request, parent) {
|
|
if (request === 'spotify-web-api-node') return FakeSpotify;
|
|
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 { Database, getDatabase } = req('src/utils/Database/Database.js');
|
|
// Bases réelles dans le dossier temporaire
|
|
const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {});
|
|
playlistsDb.data = {
|
|
'333333': [
|
|
{ playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 },
|
|
{ playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' },
|
|
{ playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] }
|
|
],
|
|
'444444': []
|
|
};
|
|
const googleDb = new Database('google', path.join(tmp, 'google.json'), {});
|
|
googleDb.data = {
|
|
'333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' },
|
|
'555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true }
|
|
};
|
|
|
|
const configuration = req('src/utils/Database/Configuration.js');
|
|
const warp = req('src/utils/WarpProxy.js');
|
|
const Admin = req('src/server/Admin.js');
|
|
|
|
// --- 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 }) }),
|
|
in: () => ({ socketsLeave() {} }),
|
|
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));
|
|
const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s));
|
|
const configEvents = [];
|
|
process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload));
|
|
const playlistEvents = [];
|
|
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
|
|
|
|
(async () => {
|
|
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE'];
|
|
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
|
|
|
|
console.log('# Configuration : validateur pur');
|
|
const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
|
|
check('clé inconnue refusée', bad('api.discord.secret', 'x'));
|
|
check('port non numérique refusé', bad('server_port', 'abc'));
|
|
check('port hors limites refusé', bad('server_port', 70000));
|
|
check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000);
|
|
check('site javascript: refusé', bad('website', 'javascript:alert(1)'));
|
|
check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org'));
|
|
check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1'));
|
|
check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin');
|
|
check('snowflake trop court refusé', bad('report.channel', '123'));
|
|
check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === '');
|
|
check('token vide refusé', bad('token', ' '));
|
|
check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def'));
|
|
check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef'));
|
|
check('type non texte refusé', bad('api.spotify.clientId', 42));
|
|
|
|
console.log('# CONFIG/GET');
|
|
let r = await call('/ADMIN/CONFIG/GET', {});
|
|
check('GET ok', r.ok, r);
|
|
check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r));
|
|
const cfg = r.data.config;
|
|
check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg));
|
|
check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token);
|
|
check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret']));
|
|
check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid');
|
|
check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false);
|
|
const integ = r.data.integrations;
|
|
check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google);
|
|
check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google);
|
|
check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null);
|
|
check('WARP non configuré', integ.warp.configured === false);
|
|
|
|
console.log('# CONFIG/SET');
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' });
|
|
check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' });
|
|
check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'website' });
|
|
check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' });
|
|
check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r);
|
|
check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents);
|
|
|
|
emitted.length = 0;
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' });
|
|
check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r);
|
|
check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz'));
|
|
check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents);
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8'));
|
|
check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token);
|
|
check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz');
|
|
await sleep(450);
|
|
const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE');
|
|
check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev));
|
|
check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz'));
|
|
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' });
|
|
check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1);
|
|
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 });
|
|
check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' });
|
|
check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false);
|
|
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' });
|
|
check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' });
|
|
check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === '');
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' });
|
|
check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
|
|
|
console.log('# CONFIG/SPOTIFY/TEST');
|
|
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
|
check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r);
|
|
check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***'));
|
|
check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false);
|
|
spotifyMode = 'ok';
|
|
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
|
check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' });
|
|
check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null);
|
|
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' });
|
|
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
|
check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
|
|
|
|
console.log('# WARP/CONFIG');
|
|
const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
|
|
const noLocks = { url: false, enabled: false, fallbackDirect: false };
|
|
check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks));
|
|
check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks));
|
|
check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks));
|
|
check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks));
|
|
check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks));
|
|
check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080');
|
|
check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null);
|
|
check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true }));
|
|
|
|
r = await call('/ADMIN/WARP/CONFIG', {});
|
|
check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
|
|
r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' });
|
|
check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r);
|
|
r = await call('/ADMIN/WARP/CONFIG', { enabled: true });
|
|
check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
|
|
// Désactivé : aucun contrôle réseau
|
|
r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false });
|
|
check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r);
|
|
check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file);
|
|
check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false);
|
|
const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8'));
|
|
check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp);
|
|
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true });
|
|
check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true);
|
|
|
|
process.env.WARP_PROXY = 'http://127.0.0.1:9';
|
|
process.env.WARP_FALLBACK_DIRECT = 'true';
|
|
r = await call('/ADMIN/WARP/STATUS', {});
|
|
check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked);
|
|
r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' });
|
|
check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r);
|
|
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false });
|
|
check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r);
|
|
delete process.env.WARP_PROXY;
|
|
delete process.env.WARP_FALLBACK_DIRECT;
|
|
r = await call('/ADMIN/WARP/CONFIG', { url: '' });
|
|
check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status);
|
|
|
|
console.log('# PLAYLISTS');
|
|
r = await call('/ADMIN/PLAYLISTS/LIST', {});
|
|
check('LIST ok', r.ok && Array.isArray(r.data), r);
|
|
check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333');
|
|
const entry = r.data[0];
|
|
check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner);
|
|
const lists = entry.playlists;
|
|
check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists);
|
|
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
|
|
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
|
|
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
|
|
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
|
|
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
|
|
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
|
|
check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
|
|
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' });
|
|
check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND');
|
|
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' });
|
|
check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r);
|
|
check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents);
|
|
|
|
console.log('# Players / comptes (correctifs)');
|
|
r = await call('/ADMIN/PLAYERS/LIST', {});
|
|
check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]);
|
|
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true });
|
|
check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r);
|
|
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false });
|
|
check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false);
|
|
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false });
|
|
check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0);
|
|
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' });
|
|
check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1);
|
|
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' });
|
|
check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
|
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
|
|
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
|
|
|
|
console.log('# Journaux');
|
|
await sleep(300);
|
|
const logDir = path.join(__dirname, 'logs');
|
|
const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n');
|
|
const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s));
|
|
check('aucun secret dans les logs', logged.length === 0, logged);
|
|
check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp')));
|
|
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
done();
|
|
setTimeout(() => process.exit(process.exitCode || 0), 50);
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|