"use strict"; // End-to-end functional check of the complete application (real bootstrap, main window, preloads, IPC, bundled // plugins) with an isolated userData and a NEW vault, against the machine it runs on over real SSH (private key, or // a password stored encrypted in the vault). // Non-destructive: only a temporary directory in the SSH user's home is created and removed; no sudo, no system // changes. Never point it at a production server. Environment (values are never printed): // CHJ_E2E_HOST, CHJ_E2E_USER, CHJ_E2E_FINGERPRINT (SHA256:base64), CHJ_E2E_REMOTE_HOME (absolute SFTP path, e.g. // /home/ai or /C:/Users/ai), CHJ_E2E_VAULT_PASSWORD_FILE and either CHJ_E2E_KEY (private key path) or // CHJ_E2E_SSH_PASSWORD_FILE (files read at runtime). Optional CHJ_E2E_SKIP (see step()). // Run: electron scripts/e2eFunctionalVm.js (Linux without a desktop: xvfb-run -a electron --no-sandbox ...) const { app, BrowserWindow, dialog } = require("electron"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const env = process.env; const required = ["CHJ_E2E_HOST", "CHJ_E2E_USER", "CHJ_E2E_FINGERPRINT", "CHJ_E2E_REMOTE_HOME", "CHJ_E2E_VAULT_PASSWORD_FILE"]; const missing = required.filter((name) => !env[name]); if (!env.CHJ_E2E_KEY && !env.CHJ_E2E_SSH_PASSWORD_FILE) missing.push("CHJ_E2E_KEY or CHJ_E2E_SSH_PASSWORD_FILE"); if (missing.length) { console.error(`Missing environment: ${missing.join(", ")}`); process.exit(2); } // Only a trailing line break is removed: the vault password may consist of spaces. const vaultPassword = fs.readFileSync(env.CHJ_E2E_VAULT_PASSWORD_FILE, "utf8").replace(/\r?\n$/, ""); const sshPassword = env.CHJ_E2E_KEY ? "" : fs.readFileSync(env.CHJ_E2E_SSH_PASSWORD_FILE, "utf8").replace(/\r?\n$/, ""); // Secret material that must never appear in userData, exports or logs. const secret = env.CHJ_E2E_KEY ? fs.readFileSync(env.CHJ_E2E_KEY, "utf8").split(/\r?\n/).filter((line) => line && !line.startsWith("-----")).sort((a, b) => b.length - a.length)[0] : sshPassword; const expectedHex = Buffer.from(env.CHJ_E2E_FINGERPRINT.replace(/^SHA256:/, ""), "base64").toString("hex"); const windows = process.platform === "win32"; const appRoot = path.resolve(__dirname, ".."); const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-e2e-")); const sessionId = "terminal-main"; const remoteDir = path.posix.join(env.CHJ_E2E_REMOTE_HOME, `chj-e2e-${crypto.randomBytes(4).toString("hex")}`); const results = []; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Isolated userData without CHJ_BUILD_SMOKE_USER_DATA_DIR (that build-smoke mode quits right after startup). Core // resolves LICENSE and docs from the app root; the license is pre-accepted like in the installed-app smoke. app.setPath("userData", root); app.getAppPath = () => appRoot; const { LicenseAcceptance } = require("../src/main/legal/licenseAcceptance"); const buildInfo = require("../src/shared/buildInfo.json"); // Registered before Core so it runs first on ready: on Linux app.getVersion() only settles once the app is ready. app.whenReady().then(() => { const acceptance = new LicenseAcceptance({ storageRoot: root, licensePath: path.join(appRoot, "LICENSE"), docsRoot: path.join(appRoot, "docs"), appVersion: app.getVersion(), buildId: buildInfo.buildId }); acceptance.accept({ accepted: true, sha256: acceptance.identity.sha256, guidanceShown: true, guidanceSha256: acceptance.identity.guidanceSha256 }); }); // Native file dialogs answer from a queue (upload source, download target, hash file, profile export target). const dialogQueue = []; dialog.showOpenDialog = async () => dialogQueue.shift() || { canceled: true, filePaths: [] }; dialog.showSaveDialog = async () => dialogQueue.shift() || { canceled: true, filePath: "" }; require("../src/main/index.js"); app.setPath("userData", root); // again after Core's app.setName() async function waitFor(check, label, timeout = 30000) { const started = Date.now(); while (Date.now() - started < timeout) { const value = await check().catch(() => null); if (value) return value; await delay(100); } throw new Error(`Timed out: ${label}`); } function run(window, body) { return window.webContents.executeJavaScript(`(async () => { ${body} })()`); } // CHJ_E2E_SKIP: comma-separated step-name prefixes to skip (for isolating a failure); skipped steps never count as passed. const skipPrefixes = String(env.CHJ_E2E_SKIP || "").split(",").map((item) => item.trim()).filter(Boolean); async function step(name, action) { if (skipPrefixes.some((prefix) => name.startsWith(prefix))) { console.log(`SKIP ${name}`); return; } try { const detail = await action(); results.push({ name, ok: true, detail: detail || "" }); console.log(`PASS ${name}${detail ? ` — ${detail}` : ""}`); } catch (error) { results.push({ name, ok: false, detail: error.message }); console.log(`FAIL ${name} — ${String(error.message).split("\n")[0].slice(0, 300)}`); } } async function pluginWindow(main, id) { await run(main, `return window.chjCore.openPlugin(${JSON.stringify(id)});`); const window = await waitFor(async () => BrowserWindow.getAllWindows().find((item) => !item.isDestroyed() && item.webContents.getURL().startsWith(`chj-plugin://${id}/`)), `${id} window`); await waitFor(() => run(window, "return typeof window.chjPlugin === 'object';"), `${id} preload`); return window; } async function main() { await app.whenReady(); const mainWindow = await waitFor(async () => BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.webContents.getURL().endsWith("renderer/index.html")), "main window"); await waitFor(() => run(mainWindow, "return typeof window.chjCore === 'object';"), "core preload"); const core = (body) => run(mainWindow, `const api = window.chjCore; ${body}`); const password = JSON.stringify(vaultPassword); await step("Core info (name, Plugin API)", async () => { const info = await core("return api.getInfo();"); assert.equal(info.name, "CH-J Server Manager"); assert.equal(info.pluginApi, require("../src/main/plugins/pluginRegistry").PLUGIN_API_VERSION); return `${info.name}, Plugin API ${info.pluginApi}, build ${buildInfo.buildId}`; }); await step("Vault: create, lock, wrong password refused, unlock", async () => { assert.equal((await core("return api.getVaultStatus();")).needsSetup, true); assert.equal((await core(`return api.createVault(${password});`)).unlocked, true); assert.equal((await core("return api.lockVault();")).unlocked, false); const wrong = await core("return api.unlockVault('wrong-password-e2e').then(() => 'accepted', (error) => 'refused');"); assert.equal(wrong, "refused"); assert.equal((await core(`return api.unlockVault(${password});`)).unlocked, true); }); let profileId; await step(env.CHJ_E2E_KEY ? "Server profile with private key" : "Server profile with password stored encrypted in the vault", async () => { const input = env.CHJ_E2E_KEY ? { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "privateKey", privateKeyPath: env.CHJ_E2E_KEY, tags: ["e2e"] } : { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "password", storePassword: true, password: sshPassword, tags: ["e2e"] }; const saved = await core(`return api.saveProfile(${JSON.stringify(input)});`); profileId = saved.id || saved.profile?.id; const list = await core("return api.listProfiles();"); assert.ok((Array.isArray(list) ? list : list.profiles).some((profile) => profile.id === profileId && profile.label === "e2e-self" && (env.CHJ_E2E_KEY ? true : profile.hasStoredPassword === true))); const regular = (file) => { try { const stat = fs.lstatSync(file); return stat.isFile() && stat.size < 4 * 1024 * 1024; } catch { return false; } }; const stored = fs.readdirSync(root, { recursive: true }).map((name) => path.join(root, String(name))).filter(regular); let locked = 0; for (const file of stored) { let data; try { data = fs.readFileSync(file); } catch (error) { if (error.code === "EBUSY" || error.code === "EPERM") { locked += 1; continue; } throw error; } assert.equal(data.includes(Buffer.from(secret)), false, `SSH secret readable in ${path.basename(file)}`); } return `${stored.length - locked} userData files contain no plaintext SSH secret${locked ? ` (${locked} files locked by Chromium skipped)` : ""}`; }); await step("Profile export JSON without secrets", async () => { const target = path.join(root, "profiles-export.json"); dialogQueue.push({ canceled: false, filePath: target }); await core("return api.exportProfiles('json');"); const text = fs.readFileSync(target, "utf8"); assert.match(text, /e2e-self/); assert.doesNotMatch(text, /PRIVATE KEY|hostKeys/); assert.equal(text.includes(secret), false, "no SSH secret in the export"); return `${text.length} B`; }); await step("SSH: unknown host key shown, fingerprint matches, trust, connect", async () => { await core("window.__e2e = ''; api.onSshData((payload) => { window.__e2e += payload?.data || ''; }); return true;"); const first = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`); assert.equal(first.ok, false); assert.equal(first.error.code, "HOST_KEY_UNKNOWN"); const fingerprint = first.error.fingerprint || first.error.details?.fingerprint; assert.equal(fingerprint, expectedHex, "presented host key equals the recorded VM fingerprint"); const trusted = await core(`return api.trustSshHostKey({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, fingerprint: ${JSON.stringify(fingerprint)} });`); assert.equal(trusted.ok, true, JSON.stringify(trusted.error)); const second = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`); assert.equal(second.ok, true, JSON.stringify(second.error)); }); await step("Terminal: command output and resize", async () => { const command = windows ? 'cmd /c "set /a 6000+77"\r\n' : "echo CHJ_E2E_$((6000+77))\n"; await core(`api.sendTerminalInput(${JSON.stringify(sessionId)}, ${JSON.stringify(command)}); return true;`); await waitFor(() => core(`return window.__e2e.includes(${JSON.stringify(windows ? "6077" : "CHJ_E2E_6077")});`), "terminal output", 20000); assert.equal((await core(`return api.resizeTerminal(${JSON.stringify(sessionId)}, 100, 28);`)).ok, true); const sessions = await core("return api.listSshSessions();"); assert.ok(JSON.stringify(sessions).includes(sessionId)); }); await step("Bundled plugins listed", async () => { const plugins = await core("return api.listPlugins();"); const ids = (Array.isArray(plugins) ? plugins : plugins.plugins || []).map((plugin) => plugin.id || plugin.manifest?.id); for (const id of ["chj.disks", "chj.file-manager", "chj.hash-checksum", "chj.key-generator", "chj.log-viewer", "chj.nginx-manager", "chj.system-monitor", "chj.users"]) assert.ok(ids.includes(id), id); return `${ids.length} plugins`; }); const sid = JSON.stringify(sessionId); await step("System Monitor: live metrics", async () => { const window = await pluginWindow(mainWindow, "chj.system-monitor"); const sessions = await run(window, "return window.chjPlugin.sessions.list();"); assert.ok(JSON.stringify(sessions).includes(sessionId)); const metrics = await run(window, `return window.chjPlugin.system.readMetrics(${sid});`); assert.ok(metrics && typeof metrics === "object"); return Object.keys(metrics).slice(0, 8).join(", "); }); await step("File Manager: list, mkdir, write/read text, upload, download, rename, delete", async () => { const window = await pluginWindow(mainWindow, "chj.file-manager"); const files = (body) => run(window, `const api = window.chjPlugin.files; ${body}`); try { return await fileChecks(window, files); } catch (error) { await files(`return api.removeMany(${sid}, [${JSON.stringify(remoteDir)}], true);`).catch(() => {}); throw error; } }); async function fileChecks(window, files) { const dir = JSON.stringify(remoteDir); const names = async (target) => { const listing = await files(`return api.list(${sid}, ${JSON.stringify(target)});`); return (Array.isArray(listing) ? listing : listing.entries).map((entry) => entry.name); }; assert.ok(Array.isArray(await names(env.CHJ_E2E_REMOTE_HOME))); await files(`return api.mkdir(${sid}, ${dir});`); assert.ok((await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir))); const note = `${remoteDir}/note.txt`; let editor = "OK"; try { // Same path as the editor UI: create, open (baseline), save, reopen. await files(`return api.createFile(${sid}, ${JSON.stringify(note)});`); const opened = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`); await files(`return api.writeText(${sid}, ${JSON.stringify(note)}, "Příliš žluťoučký kůň e2e\\n");`); const read = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`); assert.equal(read.text ?? read.content, "Příliš žluťoučký kůň e2e\n"); for (const id of [opened.editId, read.editId].filter(Boolean)) await files(`return api.closeText(${JSON.stringify(id)});`); } catch (error) { if (!windows) throw error; editor = `Windows: ${error.message.split("\n")[0].slice(0, 120)}`; } const local = path.join(root, "upload-e2e.bin"); const payload = crypto.randomBytes(256 * 1024 + 123); fs.writeFileSync(local, payload); dialogQueue.push({ canceled: false, filePaths: [local] }); await files(`return api.upload(${sid}, ${dir});`); await waitFor(async () => (await names(remoteDir)).includes("upload-e2e.bin"), "uploaded file", 30000); const downloaded = path.join(root, "download-e2e.bin"); dialogQueue.push({ canceled: false, filePath: downloaded }); await files(`return api.download(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)});`); await waitFor(async () => fs.existsSync(downloaded) && fs.statSync(downloaded).size === payload.length, "downloaded file", 30000); assert.ok(fs.readFileSync(downloaded).equals(payload), "download equals upload"); await files(`return api.rename(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)}, ${JSON.stringify(`${remoteDir}/renamed.bin`)});`); assert.ok((await names(remoteDir)).includes("renamed.bin")); await files(`return api.removeMany(${sid}, [${dir}], true);`); assert.ok(!(await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir))); return `256 KiB round trip identical; editor ${editor}`; } await step("Users: account list", async () => { const window = await pluginWindow(mainWindow, "chj.users"); const list = await run(window, `return window.chjPlugin.users.list(${sid});`); assert.ok(JSON.stringify(list).toLowerCase().includes(`"${env.CHJ_E2E_USER.toLowerCase()}"`), "SSH user is listed"); return `${(list.users || list).length ?? "?"} accounts`; }); await step("NGINX Manager: inspection", async () => { const window = await pluginWindow(mainWindow, "chj.nginx-manager"); const result = await run(window, `return window.chjPlugin.nginx.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`); assert.ok(result.ok || /not installed|nicht installiert|není nainstal|not found|unsupported|Windows/i.test(result.message), result.message); return result.ok ? `installed=${result.value?.installed ?? "?"}` : result.message.slice(0, 100); }); await step("Disk Manager: inventory without sudo / platform guard", async () => { const window = await pluginWindow(mainWindow, "chj.disks"); const result = await run(window, `return window.chjPlugin.disks.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`); if (windows) { assert.ok(!result.ok || result.value?.supported === false, "Windows is refused"); return "Windows refused as unsupported"; } assert.ok(result.ok || /sudo|privilege|oprávn|password/i.test(result.message), result.message); return result.ok ? `${(result.value.disks || []).length} disks` : "needs sudo (not tested: no sudo in this run)"; }); await step("Key Generator: Ed25519 key and grouped password", async () => { const window = await pluginWindow(mainWindow, "chj.key-generator"); const key = await run(window, "return window.chjPlugin.keys.generate({ type: 'ed25519', privateKeyFormat: 'openssh', comment: 'e2e' });"); assert.match(key.publicKey, /^ssh-ed25519 AAAA/); const pw = await run(window, "return window.chjPlugin.keys.password({ length: 20, upper: true, lower: true, digits: true, special: true, grouped: true });"); assert.match(pw.password, /^[^-]{4}-[^-]{4}-[^-]{4}-[^-]{4}-$/u); }); await step("Hash & Checksum: SHA-256 of a local file", async () => { const window = await pluginWindow(mainWindow, "chj.hash-checksum"); const file = path.join(root, "hash-e2e.txt"); fs.writeFileSync(file, "abc"); dialogQueue.push({ canceled: false, filePaths: [file] }); const selection = await run(window, "return window.chjPlugin.hashing.selectFiles(false);"); const job = await run(window, `return window.chjPlugin.hashing.start({ selectionId: ${JSON.stringify(selection.selectionId)}, algorithms: ['sha256'] });`); const jobId = JSON.stringify(job.jobId || job.id); const done = await waitFor(async () => { const status = await run(window, `return window.chjPlugin.hashing.status(${jobId});`); return ["completed", "failed", "cancelled"].includes(status.state) ? status : null; }, "hash job"); assert.equal(done.state, "completed"); assert.ok(JSON.stringify(done).includes("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); }); await step("Log Viewer: application log, no secrets", async () => { const window = await pluginWindow(mainWindow, "chj.log-viewer"); const log = await run(window, "return window.chjPlugin.logs.read({ maxBytes: 262144 });"); const text = log.text ?? log.content ?? String(log); assert.match(text, /host key trusted/i); assert.doesNotMatch(text, /PRIVATE KEY/); assert.equal(text.includes(secret), false, "no SSH secret in the log"); return `${text.length} B`; }); await step("Updates: catalog check", async () => { const state = await core("return api.checkForUpdates().then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));"); assert.ok(state.ok, state.message); assert.equal(state.value.status, "idle", "check() reports its result after finishing"); const after = await core("return api.getUpdateState();"); assert.equal(after.status, "idle"); if (after.release?.build_id === after.currentBuildId) assert.ok(after.currentVersion !== after.release.version || after.updateAvailable === false, "same build offered as an update"); return `version ${after.currentVersion}, current ${after.currentBuildId}, offered ${after.release?.build_id || after.release?.buildId || "none"} (update available: ${after.updateAvailable})`; }); await step("Disconnect and lock: sessions closed, vault locked", async () => { assert.equal((await core(`return api.disconnectSsh(${sid});`)).ok, true); assert.equal((await core("return api.lockVault();")).unlocked, false); assert.equal(JSON.stringify(await core("return api.listSshSessions();")).includes(`"state":"connected"`), false); }); } function finish(code) { for (const window of BrowserWindow.getAllWindows()) window.destroy(); const failed = results.filter((result) => !result.ok); if (failed.length || code) { // The application log holds no secrets (checked by the Log Viewer step); its tail explains failures. for (const file of fs.readdirSync(root, { recursive: true }).map(String).filter((name) => name.endsWith(".log"))) { try { console.log(`--- ${file} (tail)\n${fs.readFileSync(path.join(root, file), "utf8").split("\n").slice(-60).join("\n")}`); } catch {} } } console.log(`${failed.length || code ? "CHJ_E2E_FAILED" : "CHJ_E2E_OK"}: ${results.length - failed.length}/${results.length} steps passed on ${process.platform}`); setTimeout(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} app.exit(failed.length || code ? 1 : 0); }, 300); } main().then(() => finish(0), (error) => { console.error(error); finish(1); }); setTimeout(() => { console.error("E2E exceeded 240s"); finish(1); }, 240000).unref();