'use strict'; const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const http = require('http'); const zlib = require('zlib'); const installer = require('../../brightsign/server/bs-payload-install.js'); /* * THE POINT OF THIS FILE: a payload update must never eat the database. * * The installer replaces the server tree wholesale - it deletes each top-level directory before * moving the new one into place. That is only safe because runtime state (database, uploads, * certs, .jwt_secret) lives in DATA_DIR, OUTSIDE that tree. On the first player this was not true: * the launcher computed DATA_DIR for its own display but never exported it, so server/config.js * fell back to its own __dirname and wrote the database into server/db - inside the tree the * installer deletes. Nothing failed. The database was simply going to disappear on the first * update, silently, on a device in someone else's building. * * So these tests assert the property directly - update, then check the bytes are still there - * rather than asserting that some variable is set. */ /* -------------------------------------------------------------------------------------------- * A minimal STORED zip writer. * * Built here rather than shelling out to `zip` so the test has no external dependency, and because * hand-writing the format is what lets the malformed cases below exist at all - there is no way to * ask `zip` for an entry named "../escape.txt". * ------------------------------------------------------------------------------------------ */ function makeZip(entries) { const locals = []; const centrals = []; let offset = 0; for (const [name, contentRaw] of entries) { const content = Buffer.from(contentRaw); const nameBuf = Buffer.from(name, 'utf8'); const crc = zlib.crc32 ? zlib.crc32(content) : 0; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); // version needed local.writeUInt16LE(0, 6); // flags local.writeUInt16LE(0, 8); // method 0 = STORED local.writeUInt32LE(crc, 14); local.writeUInt32LE(content.length, 18); local.writeUInt32LE(content.length, 22); local.writeUInt16LE(nameBuf.length, 26); local.writeUInt16LE(0, 28); // extra length locals.push(local, nameBuf, content); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); // method central.writeUInt32LE(crc, 16); central.writeUInt32LE(content.length, 20); central.writeUInt32LE(content.length, 24); central.writeUInt16LE(nameBuf.length, 28); central.writeUInt32LE(offset, 42); centrals.push(central, nameBuf); offset += local.length + nameBuf.length + content.length; } const cd = Buffer.concat(centrals); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16); return Buffer.concat([...locals, cd, eocd]); } /* Serve one buffer over real HTTP - install() speaks http, so the test should too. */ async function serve(buf) { const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'application/zip', 'content-length': buf.length }); res.end(buf); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); return { url: `http://127.0.0.1:${server.address().port}/payload.zip`, close: () => new Promise((r) => server.close(r)) }; } const PAYLOAD = [ ['server/', ''], ['server/server.js', 'module.exports = "v1";\n'], ['server/routes/', ''], ['server/routes/api.js', 'module.exports = 1;\n'], ['frontend/', ''], ['frontend/index.html', '

v1

\n'], ]; function scratch() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payload-')); return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; } test('a fresh install unpacks the tree', async () => { const { dir, cleanup } = scratch(); const s = await serve(makeZip(PAYLOAD)); try { process.env.DATA_DIR = path.join(dir, 'data'); const r = await installer.install({ url: s.url, installDir: dir }); assert.strictEqual(fs.existsSync(path.join(dir, 'server', 'server.js')), true); assert.strictEqual(fs.readFileSync(path.join(dir, 'frontend', 'index.html'), 'utf8'), '

v1

\n'); assert.ok(r.files >= 3); // The archive is 70-odd MB in production and useless once unpacked. assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip')), false); } finally { await s.close(); cleanup(); delete process.env.DATA_DIR; } }); test('THE POINT: an update leaves the database, uploads and jwt secret untouched', async () => { const { dir, cleanup } = scratch(); const dataDir = path.join(dir, 'data'); try { // A device that has been running: state in DATA_DIR, and a previous payload installed. fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'uploads', 'content'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'certs'), { recursive: true }); const dbBytes = Buffer.from('SQLite format 3\0real customer data'); fs.writeFileSync(path.join(dataDir, 'db', 'remote_display.db'), dbBytes); fs.writeFileSync(path.join(dataDir, 'uploads', 'content', 'video.mp4'), 'MP4'); fs.writeFileSync(path.join(dataDir, 'certs', '.jwt_secret'), 'per-install-secret'); process.env.DATA_DIR = dataDir; const first = await serve(makeZip(PAYLOAD)); await installer.install({ url: first.url, installDir: dir }); await first.close(); // Now push an update: different contents, same shape. const v2 = PAYLOAD.map(([n, c]) => [n, String(c).replace(/v1/g, 'v2')]); const second = await serve(makeZip(v2)); await installer.install({ url: second.url, installDir: dir }); await second.close(); // The new code is in place... assert.strictEqual(fs.readFileSync(path.join(dir, 'frontend', 'index.html'), 'utf8'), '

v2

\n'); // ...and every byte of state survived it. assert.deepStrictEqual(fs.readFileSync(path.join(dataDir, 'db', 'remote_display.db')), dbBytes); assert.strictEqual(fs.readFileSync(path.join(dataDir, 'uploads', 'content', 'video.mp4'), 'utf8'), 'MP4'); assert.strictEqual(fs.readFileSync(path.join(dataDir, 'certs', '.jwt_secret'), 'utf8'), 'per-install-secret'); } finally { cleanup(); delete process.env.DATA_DIR; } }); test('refuses to install at all if DATA_DIR sits inside the tree it replaces', async () => { // The exact misconfiguration that shipped to the first player: state under server/, which the // installer deletes. Refusing loudly beats deleting a database quietly. const { dir, cleanup } = scratch(); const s = await serve(makeZip(PAYLOAD)); try { const dataDir = path.join(dir, 'server'); fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true }); fs.writeFileSync(path.join(dataDir, 'db', 'remote_display.db'), 'precious'); process.env.DATA_DIR = dataDir; await assert.rejects(() => installer.install({ url: s.url, installDir: dir }), /DATA_DIR .* is inside the payload tree/); // and it did not take the database with it on the way out assert.strictEqual(fs.readFileSync(path.join(dataDir, 'db', 'remote_display.db'), 'utf8'), 'precious'); } finally { await s.close(); cleanup(); delete process.env.DATA_DIR; } }); test('a payload missing server/server.js is rejected without touching a working install', async () => { // A truncated or wrong archive must not be able to destroy a device that is currently working. const { dir, cleanup } = scratch(); try { process.env.DATA_DIR = path.join(dir, 'data'); const good = await serve(makeZip(PAYLOAD)); await installer.install({ url: good.url, installDir: dir }); await good.close(); const bad = await serve(makeZip([['frontend/index.html', 'nope']])); await assert.rejects(() => installer.install({ url: bad.url, installDir: dir }), /no server\/server\.js/); await bad.close(); // still the working v1 install assert.strictEqual(fs.readFileSync(path.join(dir, 'server', 'server.js'), 'utf8'), 'module.exports = "v1";\n'); } finally { cleanup(); delete process.env.DATA_DIR; } }); test('an entry that escapes the destination is skipped, not written', async () => { const { dir, cleanup } = scratch(); const outside = path.join(dir, 'escaped.txt'); const s = await serve(makeZip([...PAYLOAD, ['../escaped.txt', 'pwned']])); try { process.env.DATA_DIR = path.join(dir, 'data'); const target = path.join(dir, 'install'); fs.mkdirSync(target); const r = await installer.install({ url: s.url, installDir: target }); assert.strictEqual(fs.existsSync(outside), false); assert.ok(r.skipped >= 1, 'the escaping entry should be counted as skipped'); } finally { await s.close(); cleanup(); delete process.env.DATA_DIR; } }); test('a 404 fails cleanly and leaves no half-downloaded file behind', async () => { const { dir, cleanup } = scratch(); const server = http.createServer((req, res) => { res.writeHead(404); res.end('no'); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); try { process.env.DATA_DIR = path.join(dir, 'data'); await assert.rejects( () => installer.install({ url: `http://127.0.0.1:${server.address().port}/x.zip`, installDir: dir }), /HTTP 404/); assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip')), false); assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip.part')), false); } finally { await new Promise((r) => server.close(r)); cleanup(); delete process.env.DATA_DIR; } }); test('a corrupted file is caught by its checksum instead of landing on disk', async () => { /* * Without this check a damaged byte reaches the player intact-looking and surfaces later as * something unrelated - a SyntaxError from a file nobody edited. Flip one byte in the payload * and the install must refuse rather than commit it. */ const { dir, cleanup } = scratch(); const zip = makeZip(PAYLOAD); const marker = Buffer.from('module.exports = "v1";'); const at = zip.indexOf(marker); assert.ok(at > 0, 'fixture should contain the entry body'); zip[at] = zip[at] ^ 0xff; // corrupt one byte, leave the CRC claiming otherwise const s = await serve(zip); try { process.env.DATA_DIR = path.join(dir, 'data'); await assert.rejects(() => installer.install({ url: s.url, installDir: dir }), /checksum mismatch/); assert.strictEqual(fs.existsSync(path.join(dir, 'server', 'server.js')), false); } finally { await s.close(); cleanup(); delete process.env.DATA_DIR; } });