const fs = require('fs'); const path = require('path'); const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup'); const dataDir = isolateData('cookies'); const cookies = req('src/youtube/cookies'); const NOW = 1791360000; // 2026-10-07 const FUT = 1830000000; // 2027-12 const PAST = 1700000000; const T = (...f) => f.join('\t'); let events = 0; process.on('YOUTUBE_UPDATE', () => events++); function throwsInvalid(fn) { try { fn(); return false; } catch (e) { return e.code === 'INVALID_COOKIES' && /[a-zé]/.test(e.message); } } console.log('# Netscape'); { const loggedIn = [ '# Netscape HTTP Cookie File', '# commentaire', '', T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB'), '#HttpOnly_' + T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PSID', 'CCC'), T('.youtube.com', 'TRUE', '/', 'FALSE', 0, 'PREF', 'f6=40000000'), T('.youtube.com', 'TRUE', '/', 'TRUE', PAST, 'GPS', '1'), T('.example.com', 'TRUE', '/', 'FALSE', FUT, 'tracker', 'x'), T('accounts.google.com', 'FALSE', '/', 'TRUE', FUT, '__Host-GAPS', 'g'), 'ligne cassée sans tabulation', T('.youtube.com', 'MAYBE', '/', 'TRUE', FUT, 'X', 'y'), ].join('\r\n'); const r = cookies.parseCookies(loggedIn, { now: NOW }); const names = r.cookies.map(c => c.name); check('CRLF + en-tête : cookies valides lus', names.includes('LOGIN_INFO') && names.includes('SAPISID') && names.includes('PREF'), names); check('#HttpOnly_ reconnu', r.cookies.find(c => c.name === '__Secure-3PSID')?.httpOnly === true); check('cookie de session (0) gardé', r.cookies.find(c => c.name === 'PREF')?.expires === 0); check('expiré retiré', !names.includes('GPS')); check('domaine étranger retiré', !names.includes('tracker')); check('google.com gardé (hostOnly)', r.cookies.find(c => c.name === '__Host-GAPS')?.includeSubdomains === false); check('avertissements (lignes, domaines, expirés)', r.warnings.length === 3, r.warnings); const a = cookies.analyze(r.cookies); check('analyze : loggedIn', a.loggedIn === true); check('analyze : jamais de valeur', !JSON.stringify(a).includes('AAA') && !JSON.stringify(a).includes('BBB')); check('analyze : authNames', a.authNames.includes('LOGIN_INFO') && a.authNames.includes('SAPISID') && a.authNames.includes('__Secure-3PSID'), a.authNames); check('analyze : earliestAuthExpiry ISO', a.earliestAuthExpiry === new Date(FUT * 1000).toISOString()); // Sans en-tête, LF, valeur vide, tabulations converties en espaces const noHeader = [ T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), '.youtube.com TRUE / TRUE ' + FUT + ' __Secure-3PAPISID ZZZ', T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'EMPTY', ''), ].join('\n'); const r2 = cookies.parseCookies(noHeader, { now: NOW }); check('sans en-tête + espaces au lieu de tabulations', r2.cookies.length === 3, r2.cookies.map(c => c.name)); check('valeur vide acceptée', r2.cookies.find(c => c.name === 'EMPTY')?.value === ''); check('loggedIn via __Secure-3PAPISID', cookies.analyze(r2.cookies).loggedIn === true); // Règle yt-dlp : LOGIN_INFO seul ou SAPISID seul ne suffit pas const onlySapisid = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PAPISID', 'x'); check('pas connecté sans LOGIN_INFO', cookies.analyze(cookies.parseCookies(onlySapisid, { now: NOW }).cookies).loggedIn === false); const onlyLogin = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'); const aLogin = cookies.analyze(cookies.parseCookies(onlyLogin, { now: NOW }).cookies); check('pas connecté sans SAPISID', aLogin.loggedIn === false && aLogin.missing.includes('SAPISID')); // Cookies google.com seuls : ne comptent pas pour youtube.com const googleOnly = [T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'), T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'x')].join('\n'); check('LOGIN_INFO sur google.com ne compte pas', cookies.analyze(cookies.parseCookies(googleOnly, { now: NOW }).cookies).loggedIn === false); // Sérialisation : relue à l'identique, LF, en-tête const text = cookies.serializeCookies(r.cookies); check('sérialisation : en-tête Netscape + LF', text.startsWith('# Netscape HTTP Cookie File\n') && !text.includes('\r')); const back = cookies.parseCookies(text, { now: NOW }).cookies; check('sérialisation : aller-retour', JSON.stringify(back) === JSON.stringify(r.cookies)); } console.log('# JSON'); { const arr = [ { domain: '.youtube.com', expirationDate: FUT + 0.512229, hostOnly: false, httpOnly: true, name: 'LOGIN_INFO', path: '/', sameSite: 'no_restriction', secure: true, session: false, storeId: '0', value: 'AAA', id: 1 }, { domain: '.youtube.com', expirationDate: FUT + 0.9, hostOnly: false, httpOnly: false, name: 'SAPISID', path: '/', secure: true, session: false, value: 'BBB' }, { domain: 'www.youtube.com', hostOnly: true, httpOnly: false, name: 'ST-xyz', path: '/', secure: false, session: true, value: 'S' }, { domain: 'youtube.com', hostOnly: false, name: 'NODOT', path: '/', secure: true, expires: (FUT * 1000), value: 'ms' }, { domain: '.youtube.com', name: 'OLD', expirationDate: PAST, value: 'x' }, { domain: '.facebook.com', name: 'fb', expirationDate: FUT, value: 'x' }, { nope: true }, ]; const r = cookies.parseCookies(JSON.stringify(arr), { now: NOW }); const by = Object.fromEntries(r.cookies.map(c => [c.name, c])); check('JSON : expirationDate décimal arrondi', by.LOGIN_INFO?.expires === FUT); check('JSON : session -> 0', by['ST-xyz']?.expires === 0); check('JSON : hostOnly -> sans sous-domaines', by['ST-xyz']?.includeSubdomains === false && by['ST-xyz']?.domain === 'www.youtube.com'); check('JSON : domaine non hostOnly sans point -> .youtube.com', by.NODOT?.domain === '.youtube.com' && by.NODOT?.includeSubdomains === true); check('JSON : expires en millisecondes', by.NODOT?.expires === FUT); check('JSON : httpOnly', by.LOGIN_INFO?.httpOnly === true && by.SAPISID?.httpOnly === false); check('JSON : expiré, étranger et invalide retirés', !by.OLD && !by.fb && r.warnings.length === 3, r.warnings); check('JSON : loggedIn', cookies.analyze(r.cookies).loggedIn === true); check('JSON : hasSessionOnly faux (le cookie de session n\'est pas un cookie de connexion)', cookies.analyze(r.cookies).hasSessionOnly === false); const wrapped = cookies.parseCookies(JSON.stringify({ url: 'https://www.youtube.com', cookies: arr.slice(0, 2) }), { now: NOW }); check('{cookies:[...]} accepté', wrapped.cookies.length === 2 && cookies.analyze(wrapped.cookies).loggedIn); const withBom = cookies.parseCookies('' + JSON.stringify(arr.slice(0, 2)), { now: NOW }); check('BOM ignoré', withBom.cookies.length === 2); const expiry = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'b', expiry: FUT }]), { now: NOW }); check('champ expiry (Selenium)', expiry.cookies[0].expires === FUT); const sessionAuth = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'LOGIN_INFO', value: 'b', session: true }]), { now: NOW }); check('hasSessionOnly vrai pour un cookie de connexion de session', cookies.analyze(sessionAuth.cookies).hasSessionOnly === true); } console.log('# Contenus invalides'); check('vide', throwsInvalid(() => cookies.parseCookies(' '))); check('non-chaîne', throwsInvalid(() => cookies.parseCookies(null))); check('texte quelconque', throwsInvalid(() => cookies.parseCookies('bonjour\nceci n\'est pas un fichier de cookies'))); check('JSON tronqué', throwsInvalid(() => cookies.parseCookies('[{"domain": ".youtube.com", "name": '))); check('JSON objet sans cookies', throwsInvalid(() => cookies.parseCookies('{"a": 1}'))); check('JSON liste vide', throwsInvalid(() => cookies.parseCookies('[]'))); check('HTML', throwsInvalid(() => cookies.parseCookies('Login'))); check('commentaires seuls', throwsInvalid(() => cookies.parseCookies('# Netscape HTTP Cookie File\n# rien\n'))); check('valeur avec retour à la ligne refusée', throwsInvalid(() => cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'x\ny' }])))); console.log('# Stockage'); const goodText = [T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB')].join('\n'); let threw = false; try { cookies.importCookies(T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SID', 'x')); } catch (e) { threw = e.code === 'INVALID_COOKIES'; } check('import refusé sans cookie youtube.com', threw && !fs.existsSync(__glob.YOUTUBE_COOKIES)); const before = events; const res = cookies.importCookies(goodText, { source: 'paste', by: { id: '42', username: 'raphix' } }); check('import : statut valide', res.status.valid && res.status.loggedIn && res.status.count === 2 && res.status.source === 'paste', res.status); check('import : importedBy', res.status.importedBy?.username === 'raphix'); check('import : YOUTUBE_UPDATE émis', events > before); check('import : fichier maître LF + en-tête', fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8').startsWith('# Netscape HTTP Cookie File\n')); check('statut : jamais de valeur', !JSON.stringify(cookies.getStatus()).includes('AAA')); const notLogged = cookies.importCookies(T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'PREF', 'x'), { source: 'file' }); check('import non connecté : stocké + avertissement', notLogged.status.present && !notLogged.status.loggedIn && notLogged.warnings.some(w => /connexion/.test(w)), notLogged.warnings); cookies.importCookies(goodText, { source: 'paste' }); check('markInvalid', cookies.markInvalid('test') === true && cookies.getStatus().valid === false && cookies.getStatus().invalidReason === 'test'); check('markInvalid idempotent', cookies.markInvalid('encore') === false && cookies.getStatus().invalidReason === 'test'); cookies.setLastCheck({ at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'ok' }); check('vérification réussie : invalidation levée', cookies.getStatus().valid === true); cookies.reload(); check('rechargement depuis le disque', cookies.getStatus().valid === true && cookies.getStatus().count === 2); (async () => { const copy = await cookies.acquireCopy(); check('acquireCopy : fichier identique au maître', fs.readFileSync(copy.path, 'utf8') === fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8')); check('acquireCopy : dossier chopin-ytc-', path.basename(path.dirname(copy.path)).startsWith('chopin-ytc-') && cookies.liveCopyCount() === 1); copy.release(); copy.release(); check('release : dossier supprimé', !fs.existsSync(path.dirname(copy.path)) && cookies.liveCopyCount() === 0); const del = cookies.deleteCookies(); check('deleteCookies', !del.present && !fs.existsSync(__glob.YOUTUBE_COOKIES)); check('acquireCopy sans cookies -> null', (await cookies.acquireCopy()) === null); check('aucune copie temporaire restante', chopinTmpDirs().length === 0, chopinTmpDirs()); console.log('# Fichiers réels (lecture seule, analyse sans valeurs)'); const realTxt = path.join(BACKEND, 'data', 'cookies.txt'); const realJson = path.join(BACKEND, 'data', 'cookies.json'); const mtimes = () => [realTxt, realJson].map(f => fs.existsSync(f) ? fs.statSync(f).mtimeMs : null).join(','); const mtimesBefore = mtimes(); for (const file of [realTxt, realJson]) { if (!fs.existsSync(file)) { console.log(` (absent) ${path.basename(file)}`); continue; } try { const r = cookies.parseCookies(fs.readFileSync(file, 'utf8')); console.log(` ${path.basename(file)} :`, JSON.stringify(cookies.analyze(r.cookies)), '| avertissements :', JSON.stringify(r.warnings)); } catch (e) { console.log(` ${path.basename(file)} : erreur ${e.code} ${e.message}`); } } // Migration : les anciens fichiers réels servent de source, la destination est le dossier isolé cookies.reload(); __glob.COOKIES = realTxt; __glob.COOKIES_JSON = realJson; const mig = cookies.migrateLegacy(); const st = cookies.getStatus(); console.log(' migration :', JSON.stringify(mig), '| statut :', JSON.stringify({ present: st.present, loggedIn: st.loggedIn, valid: st.valid, source: st.source, count: st.count, authNames: st.authNames, missing: st.missing, earliestAuthExpiry: st.earliestAuthExpiry })); check('migration : fichiers d\'origine non modifiés', mtimes() === mtimesBefore); check('migration : pas de seconde migration', cookies.migrateLegacy().migrated === false); fs.rmSync(dataDir, { recursive: true, force: true }); done(); })();