130 lines
13 KiB
JavaScript
130 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// Plex sign-in: does it stay signed in, and does it repair itself? Uses a fake plex.tv and a fake Plex server.
|
|
// Run: node tools/plex-test.js
|
|
const { spawn } = require('child_process');
|
|
const http = require('http');
|
|
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
|
|
|
const root = path.join(__dirname, '..');
|
|
let failed = 0;
|
|
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
|
const wait = ms => new Promise(r => setTimeout(r, ms));
|
|
const SECRET = 'plex-test-secret-'.repeat(3), UID = 'uplex01';
|
|
|
|
const listen = h => new Promise(res => { const s = http.createServer(h); s.listen(0, '127.0.0.1', () => res(s)); });
|
|
const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
|
|
|
(async () => {
|
|
// ---- fake plex.tv
|
|
const tv = { accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
|
const plexTv = await listen((rq, rs) => {
|
|
const u = new URL(rq.url, 'http://x');
|
|
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
|
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
|
if (u.pathname === '/api/v2/resources') {
|
|
tv.resourceCalls++;
|
|
if (rq.headers['x-plex-token'] !== tv.accountToken) return jsonOut(rs, 401, { error: 'bad token' });
|
|
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: true, accessToken: tv.serverToken,
|
|
connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]);
|
|
}
|
|
rs.writeHead(404); rs.end();
|
|
});
|
|
// ---- fake Plex server: /identity is open; everything else needs the server token
|
|
const seen = [], plexReqs = [];
|
|
const makePlex = () => listen((rq, rs) => {
|
|
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
|
|
plexReqs.push(rq.method + ' ' + rq.url);
|
|
seen.push(rq.headers['x-plex-token']);
|
|
if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); }
|
|
jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } });
|
|
});
|
|
let plex = await makePlex(); tv.plexPort = plex.address().port;
|
|
|
|
// ---- the hub, with one admin account already in place
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-plex-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
|
|
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
|
|
const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`;
|
|
const startHub = () => { const c = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, PLEX_TV_URL: `http://127.0.0.1:${plexTv.address().port}` } });
|
|
const h = { child: c, log: '' }; c.stdout.on('data', d => h.log += d); c.stderr.on('data', d => h.log += d); return h; };
|
|
const ready = async () => { for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); return; } catch { await wait(100); } } throw new Error('hub did not start'); };
|
|
const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); let j = {}; try { j = JSON.parse(await r.text()); } catch {} return { status: r.status, json: j }; };
|
|
// the saved file is encrypted; decrypt it the same way the server does so the test can look inside
|
|
const savedPlex = () => { try { const b = Buffer.from(JSON.parse(fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8')).data, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString()); } catch { return null; } };
|
|
|
|
let hub = startHub(); await ready();
|
|
|
|
// ================= signing in, and staying signed in =================
|
|
let r = await call('/api/plex/status'); ok('before signing in: not signed in', r.json.signedIn === false, r.json);
|
|
r = await call('/api/plex/pin', {}); ok('starting a sign-in gives a code and a plex.tv link', r.json.id === 42 && /app\.plex\.tv\/auth/.test(r.json.authUrl), r.json);
|
|
r = await call('/api/plex/pin?id=42&code=ABCD'); ok('while the person has not approved yet: not ready', r.json.ok === false, r.json);
|
|
r = await call('/api/plex/pin?id=42&code=ABCD'); ok('after they approve: signed in', r.json.ok === true, r.json);
|
|
r = await call('/api/plex/status'); ok('status: signed in, no server chosen yet', r.json.signedIn === true && r.json.server === null, r.json);
|
|
r = await call('/api/plex/servers'); ok('their servers are listed', r.json.length === 1 && r.json[0].name === 'E-va' && r.json[0].id === 'sid1', r.json);
|
|
r = await call('/api/plex/server', { id: 'sid1' }); ok('choosing the server connects', r.status === 200 && r.json.ok, r);
|
|
let s = savedPlex(); ok('everything needed to stay signed in is saved (account sign-in, server, its token, its id)', s && s.token === 'ACCOUNT' && s.stk === 'SRV1' && s.sid === 'sid1' && s.name === 'E-va' && s.srv.startsWith('http://127.0.0.1:'), s);
|
|
ok('and it is encrypted on disk', !fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8').includes('ACCOUNT'));
|
|
r = await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/status'); ok('polling the sign-in again later does not wipe the chosen server', r.json.signedIn && r.json.server === 'E-va', r.json);
|
|
const tvBefore = tv.resourceCalls;
|
|
r = await call('/plex/library/sections'); ok('Plex pages load through the hub', r.status === 200 && r.json.MediaContainer.Directory[0].title === 'Music', r);
|
|
ok('a healthy sign-in makes no extra calls to plex.tv', tv.resourceCalls === tvBefore, [tv.resourceCalls, tvBefore]);
|
|
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
|
r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json);
|
|
r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status);
|
|
|
|
// ================= playlists can be changed, nothing else can =================
|
|
plexReqs.length = 0;
|
|
r = await call('/plex/playlists?type=audio&smart=0&title=Road&uri=x', undefined, 'POST'); ok('a playlist can be created through the hub', r.status === 200 && plexReqs.includes('POST /playlists?type=audio&smart=0&title=Road&uri=x'), [r.status, plexReqs]);
|
|
r = await call('/plex/playlists/7/items?uri=x', undefined, 'PUT'); ok('and have tracks added', r.status === 200 && plexReqs.includes('PUT /playlists/7/items?uri=x'), [r.status, plexReqs]);
|
|
r = await call('/plex/playlists/7/items/55', undefined, 'DELETE'); ok('and have a track removed', r.status === 200 && plexReqs.includes('DELETE /playlists/7/items/55'), [r.status, plexReqs]);
|
|
r = await call('/plex/playlists/7', undefined, 'DELETE'); ok('and be deleted', r.status === 200 && plexReqs.includes('DELETE /playlists/7'), [r.status, plexReqs]);
|
|
plexReqs.length = 0;
|
|
const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']];
|
|
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
|
|
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
|
|
|
|
// ================= repairing itself =================
|
|
tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token
|
|
r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);
|
|
s = savedPlex(); ok('and the new token is saved', s.stk === 'SRV2' && s.token === 'ACCOUNT', s);
|
|
ok('it asked plex.tv exactly once', tv.resourceCalls === calls1 + 1, [tv.resourceCalls, calls1]);
|
|
r = await call('/plex/library/sections'); ok('the next request needs no repair', r.status === 200 && tv.resourceCalls === calls1 + 1, [r.status, tv.resourceCalls]);
|
|
const parallel = await Promise.all([1, 2, 3, 4].map(() => call('/plex/library/sections'))); ok('several requests at once all work', parallel.every(x => x.status === 200), parallel.map(x => x.status));
|
|
|
|
const oldPlex = plex; plex = await makePlex(); tv.plexPort = plex.address().port; oldPlex.close(); await wait(100); // the server moves to a new address
|
|
r = await call('/plex/library/sections'); ok('a server that moved to a new address is found again', r.status === 200, r.status);
|
|
s = savedPlex(); ok('and the new address is saved', s.srv === `http://127.0.0.1:${tv.plexPort}`, s.srv);
|
|
|
|
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
|
r = await call('/plex/library/sections'); ok('after another restart, still working on the repaired address', r.status === 200, r.status);
|
|
|
|
// ================= when it genuinely cannot continue =================
|
|
tv.accountToken = 'CHANGED'; tv.serverToken = 'SRV3'; // Plex has revoked this account sign-in
|
|
const calls2 = tv.resourceCalls;
|
|
r = await call('/plex/library/sections'); ok('if plex.tv rejects the account sign-in, the request fails cleanly (401)', r.status === 401, r.status);
|
|
r = await call('/api/plex/status'); ok('and the person is shown as signed out, so they are asked to sign in again', r.json.signedIn === false, r.json);
|
|
ok('only one attempt was made', tv.resourceCalls === calls2 + 1, [tv.resourceCalls, calls2]);
|
|
|
|
// a dead server: do not hammer plex.tv
|
|
tv.accountToken = 'ACCOUNT'; tv.serverToken = 'SRV1'; await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/server', { id: 'sid1' });
|
|
ok('signing in again and choosing the server works', r.status === 200, r);
|
|
plex.close(); await wait(100); const calls3 = tv.resourceCalls;
|
|
r = await call('/plex/library/sections'); const afterFirst = tv.resourceCalls; r = await call('/plex/library/sections'); r = await call('/plex/library/sections');
|
|
ok('with the server down it gives up quickly and does not hammer plex.tv (at most one lookup for three requests)', r.status >= 400 && tv.resourceCalls - calls3 <= 1 && tv.resourceCalls === afterFirst, [r.status, calls3, afterFirst, tv.resourceCalls]);
|
|
ok('the saved sign-in is kept while the server is simply offline', (savedPlex() || {}).token === 'ACCOUNT', savedPlex());
|
|
plex = await makePlex(); tv.plexPort = plex.address().port;
|
|
r = await call('/api/plex/logout', {}); r = await call('/api/plex/status'); ok('signing out removes everything', r.json.signedIn === false && !fs.existsSync(path.join(dir, 'users', UID, 'plex.json')), r.json);
|
|
|
|
// a sign-in saved by an older version has no server id: it is matched by the server's name and the id is remembered
|
|
const seal = o => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), iv); const d = Buffer.concat([c.update(JSON.stringify(o), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
|
|
fs.mkdirSync(path.join(dir, 'users', UID), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'users', UID, 'plex.json'), JSON.stringify({ data: seal({ token: 'ACCOUNT', srv: 'http://127.0.0.1:1', stk: 'STALE', name: 'E-va' }) })); // no sid, dead address, stale token
|
|
await wait(31000 * 0 + 1); hub.child.kill(); await wait(400); hub = startHub(); await ready(); // a fresh process: no repair pause
|
|
r = await call('/plex/library/sections'); s = savedPlex();
|
|
ok('a sign-in saved by an older version (no server id) repairs itself by server name', r.status === 200 && s.sid === 'sid1' && s.stk === 'SRV1', [r.status, s]);
|
|
|
|
ok('the hub logged what it did (for support)', /\[plex\] (signed in|connected|repaired)/.test(hub.log), hub.log.split('\n').filter(l => /plex/.test(l)).slice(0, 4));
|
|
hub.child.kill(); plex.close(); plexTv.close(); fs.rmSync(dir, { recursive: true, force: true });
|
|
console.log(failed ? `\n${failed} check(s) failed` : '\nAll Plex checks passed');
|
|
process.exit(failed ? 1 : 0);
|
|
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
|