'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const config = require('../config/config'); const { createConsoleDeployGraph } = require('../lib/consoleDeployGraph'); const { GITHUB_COMPARE_FILE_LIMIT, isCompareLikelyTruncated, isRetryableDownloadStatus, getDownloadRetryDelayMs, ensureGoServerSignalRelayPorts, restoreServerBinaryBackup, restoreFromBackup, } = require('../services/updateService'); describe('updateService console sync helpers', () => { test('detects truncated GitHub compare responses', () => { expect(GITHUB_COMPARE_FILE_LIMIT).toBe(300); expect(isCompareLikelyTruncated(299)).toBe(false); expect(isCompareLikelyTruncated(300)).toBe(true); expect(isCompareLikelyTruncated(450)).toBe(true); }); test('resolves relative console require paths', () => { const graph = createConsoleDeployGraph(require('path').join(__dirname, '..')); expect(graph.resolveConsoleRequire('routes/auth.routes.js', '../services/serverAttestation')) .toBe('services/serverAttestation.js'); expect(graph.resolveConsoleRequire('routes/index.js', './devices.routes')) .toBe('routes/devices.routes.js'); expect(graph.resolveConsoleRequire('server.js', './routes')) .toBe('routes/index.js'); expect(graph.resolveConsoleRequire('server.js', 'express')).toBeNull(); }); test('skips phantom routes.js when routes/index.js exists during repair scan', () => { const graph = createConsoleDeployGraph(require('path').join(__dirname, '..')); expect(graph.isResolvedByIndexModule('routes.js')).toBe(true); expect(graph.isResolvedByIndexModule('routes/auth.routes.js')).toBe(false); }); test('ignores require examples inside comments when scanning dependencies', () => { const graph = createConsoleDeployGraph(require('path').join(__dirname, '..')); const required = graph.collectConsoleRequiredFiles([ { localPath: 'scripts/linux-ensure-console-user.js' }, ]); expect(required.has('scripts/linux-ensure-console-user.js')).toBe(true); expect(required.has('scripts/scripts/linux-ensure-console-user.js')).toBe(false); expect(required.has('routes.js')).toBe(false); expect(required.has('routes/index.js')).toBe(true); }); test('collects serverAttestation from auth.routes integrity seeds', () => { const graph = createConsoleDeployGraph(require('path').join(__dirname, '..')); const required = graph.collectConsoleRequiredFiles([ { localPath: 'routes/auth.routes.js' }, ]); expect(required.has('routes/auth.routes.js')).toBe(true); expect(required.has('services/serverAttestation.js')).toBe(true); }); test('retries GitHub raw downloads on rate limit status codes', () => { expect(isRetryableDownloadStatus(429)).toBe(true); expect(isRetryableDownloadStatus(503)).toBe(true); expect(isRetryableDownloadStatus(404)).toBe(false); expect(getDownloadRetryDelayMs(1)).toBe(1000); expect(getDownloadRetryDelayMs(2)).toBe(2000); expect(getDownloadRetryDelayMs(6)).toBe(15000); }); test('ensureGoServerSignalRelayPorts adds SIGNAL_PORT, RELAY_PORT, and GO_API_PORT (#219)', () => { const unit = [ '[Service]', 'User=root', 'Environment=AUTH_DB_PATH=/opt/console/data/auth.db', 'ExecStart=/opt/betterdesk/betterdesk-server -mode all', ].join('\n'); const patched = ensureGoServerSignalRelayPorts(unit); expect(patched.changed).toBe(true); expect(patched.text).toMatch(/^Environment=SIGNAL_PORT=21116$/m); expect(patched.text).toMatch(/^Environment=RELAY_PORT=21117$/m); expect(patched.text).toMatch(/^Environment=GO_API_PORT=21114$/m); const again = ensureGoServerSignalRelayPorts(patched.text); expect(again.changed).toBe(false); }); test('restores a validated server binary backup atomically', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-binary-rollback-')); const target = path.join(root, 'betterdesk-server.exe'); const backup = `${target}.bak.test`; try { fs.writeFileSync(target, 'new'); fs.writeFileSync(backup, 'old'); expect(restoreServerBinaryBackup(backup, target)).toMatchObject({ restored: true }); expect(fs.readFileSync(target, 'utf8')).toBe('old'); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('rejects a binary backup outside the target directory', () => { expect(restoreServerBinaryBackup( path.join(os.tmpdir(), 'betterdesk-server.exe.bak.test'), path.join(os.tmpdir(), 'other', 'betterdesk-server.exe') )).toMatchObject({ restored: false, error: 'Server binary backup path failed validation', }); }); test('rejects traversal paths from a backup manifest', () => { const backupRoot = path.join(config.dataDir, 'backups'); const backupName = `pre-update-${Date.now()}-${process.pid}`; const backupPath = path.join(backupRoot, backupName); fs.mkdirSync(backupPath, { recursive: true }); fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({ files: ['../outside.txt'], })); try { expect(() => restoreFromBackup(backupName)) .toThrow('Invalid path in backup manifest'); } finally { fs.rmSync(backupPath, { recursive: true, force: true }); } }); test('refuses to restore protected runtime files from a backup manifest', () => { const backupRoot = path.join(config.dataDir, 'backups'); const backupName = `pre-update-${Date.now()}-${process.pid}`; const backupPath = path.join(backupRoot, backupName); fs.mkdirSync(backupPath, { recursive: true }); fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({ files: ['console/.env'], })); try { expect(() => restoreFromBackup(backupName)) .toThrow('Refusing to restore protected runtime path'); } finally { fs.rmSync(backupPath, { recursive: true, force: true }); } }); test('refuses to remove protected runtime files from a backup manifest', () => { const backupRoot = path.join(config.dataDir, 'backups'); const backupName = `pre-update-${Date.now()}-${process.pid}`; const backupPath = path.join(backupRoot, backupName); fs.mkdirSync(backupPath, { recursive: true }); fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({ removeOnRestore: ['console/.env'], })); try { expect(() => restoreFromBackup(backupName)) .toThrow('Refusing to remove protected runtime path'); } finally { fs.rmSync(backupPath, { recursive: true, force: true }); } }); });