Version 2.0.0 - update of everything

This commit is contained in:
raphix committed 2026-10-07 14:04:26 +02:00
1 parent 164a1da009
commit 6017e03eac
85 files changed
+13065 -2107

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
// 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); });