Files
Twitch-VOD-Manager/scripts/e2e-test-environment.js
T
Sucukdeluxe 47be523b41 feat(security): encrypt secrets and centralize app state
Store Twitch and Discord secrets as versioned safeStorage ciphertext behind explicit trusted IPC. Migrate legacy JSON transactionally into authoritative SQLite config and ordered queue repositories with rollback-safe markers and sanitized backups. Redact config exports and update renderer and release-harness contracts for secret-free config responses.
2026-08-11 23:44:20 +02:00

259 lines
8.2 KiB
JavaScript

const fs = require('fs');
const os = require('os');
const path = require('path');
const OFFLINE_PROXY = 'http://127.0.0.1:1';
function buildSafeConfig(downloadsDir, overrides = {}) {
return {
client_id: '',
download_path: downloadsDir,
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
language: 'en',
filename_template_vod: '{title}.mp4',
filename_template_parts: '{date}_Part{part_padded}.mp4',
filename_template_clip: '{date}_{part}.mp4',
smart_queue_scheduler: false,
performance_mode: 'balanced',
prevent_duplicate_downloads: true,
persist_queue_on_restart: false,
metadata_cache_minutes: 10,
parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: [],
streamlink_quality: 'best',
notify_on_each_completion: false,
streamlink_disable_ads: true,
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_cleanup_days: 30,
auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive',
log_stream_events: false,
auto_vod_download_streamers: [],
auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24,
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false,
...overrides,
client_id: '',
download_path: downloadsDir,
streamers: [],
auto_resume_queue_on_startup: false,
auto_record_streamers: [],
download_chat_replay: false,
capture_live_chat: false,
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_vod_download_streamers: [],
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false
};
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function createE2eEnvironment(name, configOverrides = {}) {
const safeName = String(name || 'smoke').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), `twitch-vod-manager-${safeName}-`));
const programDataDir = path.join(rootDir, 'programdata');
const userDataDir = path.join(rootDir, 'userdata');
const downloadsDir = path.join(rootDir, 'downloads');
const appDataDir = path.join(programDataDir, 'Twitch_VOD_Manager');
const mediaDir = path.join(rootDir, 'media');
const configFile = path.join(appDataDir, 'config.json');
const queueFile = path.join(appDataDir, 'download_queue.json');
for (const directory of [programDataDir, userDataDir, downloadsDir, appDataDir, mediaDir]) {
fs.mkdirSync(directory, { recursive: true });
}
writeJson(configFile, buildSafeConfig(downloadsDir, configOverrides));
writeJson(queueFile, []);
return {
rootDir,
programDataDir,
userDataDir,
downloadsDir,
appDataDir,
mediaDir,
configFile,
queueFile
};
}
function writeE2eConfig(environment, overrides = {}) {
const config = buildSafeConfig(environment.downloadsDir, overrides);
writeJson(environment.configFile, config);
return config;
}
function readE2eConfig(environment) {
const databasePath = path.join(environment.appDataDir, 'app.db');
if (fs.existsSync(databasePath)) {
const Database = require('better-sqlite3');
const database = new Database(databasePath, { readonly: true });
try {
const rows = database.prepare('SELECT key, value FROM config_kv').all();
return Object.fromEntries(rows.map((row) => [row.key, JSON.parse(row.value)]));
} finally {
database.close();
}
}
return JSON.parse(fs.readFileSync(environment.configFile, 'utf8'));
}
function getElectronLaunchOptions(environment, extraArgs = []) {
return {
executablePath: require('electron'),
args: [
`--user-data-dir=${environment.userDataDir}`,
`--proxy-server=${OFFLINE_PROXY}`,
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost',
...extraArgs,
'.'
],
cwd: path.resolve(__dirname, '..'),
env: {
...process.env,
PROGRAMDATA: environment.programDataDir,
HTTP_PROXY: OFFLINE_PROXY,
HTTPS_PROXY: OFFLINE_PROXY,
ALL_PROXY: OFFLINE_PROXY,
NO_PROXY: '',
http_proxy: OFFLINE_PROXY,
https_proxy: OFFLINE_PROXY,
all_proxy: OFFLINE_PROXY,
no_proxy: ''
}
};
}
function isSamePath(left, right) {
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
}
async function verifyE2eIsolation(app, win, environment) {
const main = await app.evaluate(({ app: electronApp }) => ({
programData: process.env.PROGRAMDATA || '',
userData: electronApp.getPath('userData')
}));
const rendererDownloadPath = await win.evaluate(async () => {
const currentConfig = await window.api.getConfig();
return currentConfig.download_path;
});
const verification = {
rootDir: environment.rootDir,
programData: main.programData,
userData: main.userData,
downloadPath: rendererDownloadPath,
programDataIsolated: isSamePath(main.programData, environment.programDataDir),
userDataIsolated: isSamePath(main.userData, environment.userDataDir),
downloadPathIsolated: isSamePath(rendererDownloadPath, environment.downloadsDir)
};
if (!verification.programDataIsolated || !verification.userDataIsolated || !verification.downloadPathIsolated) {
throw new Error(`E2E isolation verification failed: ${JSON.stringify(verification)}`);
}
return verification;
}
async function installOfflineFixtures(app) {
return app.evaluate(({ app: electronApp, ipcMain }) => {
const offlineState = {
httpProxy: process.env.HTTP_PROXY || '',
httpsProxy: process.env.HTTPS_PROXY || '',
allProxy: process.env.ALL_PROXY || '',
noProxy: process.env.NO_PROXY ?? null,
chromiumProxy: electronApp.commandLine.getSwitchValue('proxy-server')
};
const guardsActive =
offlineState.httpProxy === offlineState.httpsProxy &&
offlineState.httpProxy === offlineState.allProxy &&
offlineState.httpProxy.startsWith('http://127.0.0.1:') &&
offlineState.noProxy === '' &&
offlineState.chromiumProxy === offlineState.httpProxy;
if (!guardsActive) {
throw new Error(`Offline bootstrap verification failed: ${JSON.stringify(offlineState || null)}`);
}
const vods = [{
id: '999999999999999',
title: 'Offline fixture VOD',
created_at: '2026-02-01T00:00:00Z',
duration: '1h0m0s',
thumbnail_url: '',
url: 'https://www.twitch.tv/videos/999999999999999',
view_count: 123,
stream_id: 'offline-fixture-stream'
}];
const replaceHandler = (channel, handler) => {
ipcMain.removeHandler(channel);
ipcMain.handle(channel, handler);
};
replaceHandler('get-user-id', async () => 'offline-fixture-user');
replaceHandler('get-vods', async () => vods);
replaceHandler('get-streamer-profile', async () => null);
replaceHandler('run-preflight', async () => ({
ok: true,
autoFixApplied: false,
checks: {
internet: true,
streamlink: true,
ffmpeg: true,
ffprobe: true,
downloadPathWritable: true
},
messages: ['Offline fixture'],
timestamp: '2026-01-01T00:00:00Z'
}));
replaceHandler('check-update', async () => ({ checking: true, offlineFixture: true }));
return {
network: 'blocked',
twitch: 'fixture',
updater: 'fixture',
guards: offlineState
};
});
}
function cleanupE2eEnvironment(environment) {
if (!environment?.rootDir) {
return;
}
fs.rmSync(environment.rootDir, { recursive: true, force: true });
}
module.exports = {
buildSafeConfig,
createE2eEnvironment,
writeE2eConfig,
readE2eConfig,
getElectronLaunchOptions,
verifyE2eIsolation,
installOfflineFixtures,
cleanupE2eEnvironment
};