build-container / build (push) Successful in 2m24s
The saved Plex server token or address can stop working (token rotated, server moved). That looked like "signed out" and sent the person back to the sign-in screen. The proxy now asks plex.tv (using the saved account sign-in) for a fresh token and address, retries once, and saves them. Sign-ins saved before the server id was recorded are matched by server name. A revoked account sign-in signs the person out cleanly; repairs are one at a time and pause 30s after a failure. The server logs [plex] lines for sign-in, connect, repair and failure. tools/plex-test.js (31 checks, fake plex.tv + fake Plex) runs in the Gitea workflow. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
118 lines
11 KiB
JavaScript
118 lines
11 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 = [];
|
|
const makePlex = () => listen((rq, rs) => {
|
|
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
|
|
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);
|
|
|
|
// ================= 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); });
|