Opublikowane źródło
Pobierz źródło ZIP
CH-J Tools
Przeglądaj katalogi i pliki dla konkretnego wydania aplikacji.
CH-J / Server Manager
406 pliki
core-20261024T135205Z-7c487d719de2
Dodano: · Built:
1
"use strict";2
// End-to-end functional check of the complete application (real bootstrap, main window, preloads, IPC, bundled3
// plugins) with an isolated userData and a NEW vault, against the machine it runs on over real SSH (private key, or4
// a password stored encrypted in the vault).5
// Non-destructive: only a temporary directory in the SSH user's home is created and removed; no sudo, no system6
// changes. Never point it at a production server. Environment (values are never printed):7
// CHJ_E2E_HOST, CHJ_E2E_USER, CHJ_E2E_FINGERPRINT (SHA256:base64), CHJ_E2E_REMOTE_HOME (absolute SFTP path, e.g.8
// /home/ai or /C:/Users/ai), CHJ_E2E_VAULT_PASSWORD_FILE and either CHJ_E2E_KEY (private key path) or9
// CHJ_E2E_SSH_PASSWORD_FILE (files read at runtime). Optional CHJ_E2E_SKIP (see step()).10
// Run: electron scripts/e2eFunctionalVm.js (Linux without a desktop: xvfb-run -a electron --no-sandbox ...)11
const { app, BrowserWindow, dialog } = require("electron");12
const assert = require("node:assert/strict");13
const crypto = require("node:crypto");14
const fs = require("node:fs");15
const os = require("node:os");16
const path = require("node:path");18
const env = process.env;19
const required = ["CHJ_E2E_HOST", "CHJ_E2E_USER", "CHJ_E2E_FINGERPRINT", "CHJ_E2E_REMOTE_HOME", "CHJ_E2E_VAULT_PASSWORD_FILE"];20
const missing = required.filter((name) => !env[name]);21
if (!env.CHJ_E2E_KEY && !env.CHJ_E2E_SSH_PASSWORD_FILE) missing.push("CHJ_E2E_KEY or CHJ_E2E_SSH_PASSWORD_FILE");22
if (missing.length) { console.error(`Missing environment: ${missing.join(", ")}`); process.exit(2); }23
// Only a trailing line break is removed: the vault password may consist of spaces.24
const vaultPassword = fs.readFileSync(env.CHJ_E2E_VAULT_PASSWORD_FILE, "utf8").replace(/\r?\n$/, "");25
const sshPassword = env.CHJ_E2E_KEY ? "" : fs.readFileSync(env.CHJ_E2E_SSH_PASSWORD_FILE, "utf8").replace(/\r?\n$/, "");26
// Secret material that must never appear in userData, exports or logs.27
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;28
const expectedHex = Buffer.from(env.CHJ_E2E_FINGERPRINT.replace(/^SHA256:/, ""), "base64").toString("hex");29
const windows = process.platform === "win32";30
const appRoot = path.resolve(__dirname, "..");31
const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-e2e-"));32
const sessionId = "terminal-main";33
const remoteDir = path.posix.join(env.CHJ_E2E_REMOTE_HOME, `chj-e2e-${crypto.randomBytes(4).toString("hex")}`);34
const results = [];35
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));37
// Isolated userData without CHJ_BUILD_SMOKE_USER_DATA_DIR (that build-smoke mode quits right after startup). Core38
// resolves LICENSE and docs from the app root; the license is pre-accepted like in the installed-app smoke.39
app.setPath("userData", root);40
app.getAppPath = () => appRoot;41
const { LicenseAcceptance } = require("../src/main/legal/licenseAcceptance");42
const buildInfo = require("../src/shared/buildInfo.json");43
// Registered before Core so it runs first on ready: on Linux app.getVersion() only settles once the app is ready.44
app.whenReady().then(() => {45
const acceptance = new LicenseAcceptance({ storageRoot: root, licensePath: path.join(appRoot, "LICENSE"), docsRoot: path.join(appRoot, "docs"), appVersion: app.getVersion(), buildId: buildInfo.buildId });46
acceptance.accept({ accepted: true, sha256: acceptance.identity.sha256, guidanceShown: true, guidanceSha256: acceptance.identity.guidanceSha256 });47
});49
// Native file dialogs answer from a queue (upload source, download target, hash file, profile export target).50
const dialogQueue = [];51
dialog.showOpenDialog = async () => dialogQueue.shift() || { canceled: true, filePaths: [] };52
dialog.showSaveDialog = async () => dialogQueue.shift() || { canceled: true, filePath: "" };54
require("../src/main/index.js");55
app.setPath("userData", root); // again after Core's app.setName()57
async function waitFor(check, label, timeout = 30000) {58
const started = Date.now();59
while (Date.now() - started < timeout) { const value = await check().catch(() => null); if (value) return value; await delay(100); }60
throw new Error(`Timed out: ${label}`);61
}62
function run(window, body) { return window.webContents.executeJavaScript(`(async () => { ${body} })()`); }63
// CHJ_E2E_SKIP: comma-separated step-name prefixes to skip (for isolating a failure); skipped steps never count as passed.64
const skipPrefixes = String(env.CHJ_E2E_SKIP || "").split(",").map((item) => item.trim()).filter(Boolean);65
async function step(name, action) {66
if (skipPrefixes.some((prefix) => name.startsWith(prefix))) { console.log(`SKIP ${name}`); return; }67
try { const detail = await action(); results.push({ name, ok: true, detail: detail || "" }); console.log(`PASS ${name}${detail ? ` — ${detail}` : ""}`); }68
catch (error) { results.push({ name, ok: false, detail: error.message }); console.log(`FAIL ${name} — ${String(error.message).split("\n")[0].slice(0, 300)}`); }69
}70
async function pluginWindow(main, id) {71
await run(main, `return window.chjCore.openPlugin(${JSON.stringify(id)});`);72
const window = await waitFor(async () => BrowserWindow.getAllWindows().find((item) => !item.isDestroyed() && item.webContents.getURL().startsWith(`chj-plugin://${id}/`)), `${id} window`);73
await waitFor(() => run(window, "return typeof window.chjPlugin === 'object';"), `${id} preload`);74
return window;75
}77
async function main() {78
await app.whenReady();79
const mainWindow = await waitFor(async () => BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.webContents.getURL().endsWith("renderer/index.html")), "main window");80
await waitFor(() => run(mainWindow, "return typeof window.chjCore === 'object';"), "core preload");81
const core = (body) => run(mainWindow, `const api = window.chjCore; ${body}`);82
const password = JSON.stringify(vaultPassword);84
await step("Core info (name, Plugin API)", async () => {85
const info = await core("return api.getInfo();");86
assert.equal(info.name, "CH-J Server Manager");87
assert.equal(info.pluginApi, require("../src/main/plugins/pluginRegistry").PLUGIN_API_VERSION);88
return `${info.name}, Plugin API ${info.pluginApi}, build ${buildInfo.buildId}`;89
});90
await step("Vault: create, lock, wrong password refused, unlock", async () => {91
assert.equal((await core("return api.getVaultStatus();")).needsSetup, true);92
assert.equal((await core(`return api.createVault(${password});`)).unlocked, true);93
assert.equal((await core("return api.lockVault();")).unlocked, false);94
const wrong = await core("return api.unlockVault('wrong-password-e2e').then(() => 'accepted', (error) => 'refused');");95
assert.equal(wrong, "refused");96
assert.equal((await core(`return api.unlockVault(${password});`)).unlocked, true);97
});98
let profileId;99
await step(env.CHJ_E2E_KEY ? "Server profile with private key" : "Server profile with password stored encrypted in the vault", async () => {100
const input = env.CHJ_E2E_KEY101
? { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "privateKey", privateKeyPath: env.CHJ_E2E_KEY, tags: ["e2e"] }102
: { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "password", storePassword: true, password: sshPassword, tags: ["e2e"] };103
const saved = await core(`return api.saveProfile(${JSON.stringify(input)});`);104
profileId = saved.id || saved.profile?.id;105
const list = await core("return api.listProfiles();");106
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)));107
const regular = (file) => { try { const stat = fs.lstatSync(file); return stat.isFile() && stat.size < 4 * 1024 * 1024; } catch { return false; } };108
const stored = fs.readdirSync(root, { recursive: true }).map((name) => path.join(root, String(name))).filter(regular);109
let locked = 0;110
for (const file of stored) {111
let data;112
try { data = fs.readFileSync(file); } catch (error) { if (error.code === "EBUSY" || error.code === "EPERM") { locked += 1; continue; } throw error; }113
assert.equal(data.includes(Buffer.from(secret)), false, `SSH secret readable in ${path.basename(file)}`);114
}115
return `${stored.length - locked} userData files contain no plaintext SSH secret${locked ? ` (${locked} files locked by Chromium skipped)` : ""}`;116
});117
await step("Profile export JSON without secrets", async () => {118
const target = path.join(root, "profiles-export.json");119
dialogQueue.push({ canceled: false, filePath: target });120
await core("return api.exportProfiles('json');");121
const text = fs.readFileSync(target, "utf8");122
assert.match(text, /e2e-self/);123
assert.doesNotMatch(text, /PRIVATE KEY|hostKeys/);124
assert.equal(text.includes(secret), false, "no SSH secret in the export");125
return `${text.length} B`;126
});127
await step("SSH: unknown host key shown, fingerprint matches, trust, connect", async () => {128
await core("window.__e2e = ''; api.onSshData((payload) => { window.__e2e += payload?.data || ''; }); return true;");129
const first = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`);130
assert.equal(first.ok, false); assert.equal(first.error.code, "HOST_KEY_UNKNOWN");131
const fingerprint = first.error.fingerprint || first.error.details?.fingerprint;132
assert.equal(fingerprint, expectedHex, "presented host key equals the recorded VM fingerprint");133
const trusted = await core(`return api.trustSshHostKey({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, fingerprint: ${JSON.stringify(fingerprint)} });`);134
assert.equal(trusted.ok, true, JSON.stringify(trusted.error));135
const second = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`);136
assert.equal(second.ok, true, JSON.stringify(second.error));137
});138
await step("Terminal: command output and resize", async () => {139
const command = windows ? 'cmd /c "set /a 6000+77"\r\n' : "echo CHJ_E2E_$((6000+77))\n";140
await core(`api.sendTerminalInput(${JSON.stringify(sessionId)}, ${JSON.stringify(command)}); return true;`);141
await waitFor(() => core(`return window.__e2e.includes(${JSON.stringify(windows ? "6077" : "CHJ_E2E_6077")});`), "terminal output", 20000);142
assert.equal((await core(`return api.resizeTerminal(${JSON.stringify(sessionId)}, 100, 28);`)).ok, true);143
const sessions = await core("return api.listSshSessions();");144
assert.ok(JSON.stringify(sessions).includes(sessionId));145
});146
await step("Bundled plugins listed", async () => {147
const plugins = await core("return api.listPlugins();");148
const ids = (Array.isArray(plugins) ? plugins : plugins.plugins || []).map((plugin) => plugin.id || plugin.manifest?.id);149
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);150
return `${ids.length} plugins`;151
});153
const sid = JSON.stringify(sessionId);154
await step("System Monitor: live metrics", async () => {155
const window = await pluginWindow(mainWindow, "chj.system-monitor");156
const sessions = await run(window, "return window.chjPlugin.sessions.list();");157
assert.ok(JSON.stringify(sessions).includes(sessionId));158
const metrics = await run(window, `return window.chjPlugin.system.readMetrics(${sid});`);159
assert.ok(metrics && typeof metrics === "object");160
return Object.keys(metrics).slice(0, 8).join(", ");161
});162
await step("File Manager: list, mkdir, write/read text, upload, download, rename, delete", async () => {163
const window = await pluginWindow(mainWindow, "chj.file-manager");164
const files = (body) => run(window, `const api = window.chjPlugin.files; ${body}`);165
try { return await fileChecks(window, files); }166
catch (error) { await files(`return api.removeMany(${sid}, [${JSON.stringify(remoteDir)}], true);`).catch(() => {}); throw error; }167
});168
async function fileChecks(window, files) {169
const dir = JSON.stringify(remoteDir);170
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); };171
assert.ok(Array.isArray(await names(env.CHJ_E2E_REMOTE_HOME)));172
await files(`return api.mkdir(${sid}, ${dir});`);173
assert.ok((await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir)));174
const note = `${remoteDir}/note.txt`;175
let editor = "OK";176
try {177
// Same path as the editor UI: create, open (baseline), save, reopen.178
await files(`return api.createFile(${sid}, ${JSON.stringify(note)});`);179
const opened = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`);180
await files(`return api.writeText(${sid}, ${JSON.stringify(note)}, "Příliš žluťoučký kůň e2e\\n");`);181
const read = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`);182
assert.equal(read.text ?? read.content, "Příliš žluťoučký kůň e2e\n");183
for (const id of [opened.editId, read.editId].filter(Boolean)) await files(`return api.closeText(${JSON.stringify(id)});`);184
} catch (error) { if (!windows) throw error; editor = `Windows: ${error.message.split("\n")[0].slice(0, 120)}`; }185
const local = path.join(root, "upload-e2e.bin");186
const payload = crypto.randomBytes(256 * 1024 + 123);187
fs.writeFileSync(local, payload);188
dialogQueue.push({ canceled: false, filePaths: [local] });189
await files(`return api.upload(${sid}, ${dir});`);190
await waitFor(async () => (await names(remoteDir)).includes("upload-e2e.bin"), "uploaded file", 30000);191
const downloaded = path.join(root, "download-e2e.bin");192
dialogQueue.push({ canceled: false, filePath: downloaded });193
await files(`return api.download(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)});`);194
await waitFor(async () => fs.existsSync(downloaded) && fs.statSync(downloaded).size === payload.length, "downloaded file", 30000);195
assert.ok(fs.readFileSync(downloaded).equals(payload), "download equals upload");196
await files(`return api.rename(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)}, ${JSON.stringify(`${remoteDir}/renamed.bin`)});`);197
assert.ok((await names(remoteDir)).includes("renamed.bin"));198
await files(`return api.removeMany(${sid}, [${dir}], true);`);199
assert.ok(!(await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir)));200
return `256 KiB round trip identical; editor ${editor}`;201
}202
await step("Users: account list", async () => {203
const window = await pluginWindow(mainWindow, "chj.users");204
const list = await run(window, `return window.chjPlugin.users.list(${sid});`);205
assert.ok(JSON.stringify(list).toLowerCase().includes(`"${env.CHJ_E2E_USER.toLowerCase()}"`), "SSH user is listed");206
return `${(list.users || list).length ?? "?"} accounts`;207
});208
await step("NGINX Manager: inspection", async () => {209
const window = await pluginWindow(mainWindow, "chj.nginx-manager");210
const result = await run(window, `return window.chjPlugin.nginx.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`);211
assert.ok(result.ok || /not installed|nicht installiert|není nainstal|not found|unsupported|Windows/i.test(result.message), result.message);212
return result.ok ? `installed=${result.value?.installed ?? "?"}` : result.message.slice(0, 100);213
});214
await step("Disk Manager: inventory without sudo / platform guard", async () => {215
const window = await pluginWindow(mainWindow, "chj.disks");216
const result = await run(window, `return window.chjPlugin.disks.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`);217
if (windows) { assert.ok(!result.ok || result.value?.supported === false, "Windows is refused"); return "Windows refused as unsupported"; }218
assert.ok(result.ok || /sudo|privilege|oprávn|password/i.test(result.message), result.message);219
return result.ok ? `${(result.value.disks || []).length} disks` : "needs sudo (not tested: no sudo in this run)";220
});221
await step("Key Generator: Ed25519 key and grouped password", async () => {222
const window = await pluginWindow(mainWindow, "chj.key-generator");223
const key = await run(window, "return window.chjPlugin.keys.generate({ type: 'ed25519', privateKeyFormat: 'openssh', comment: 'e2e' });");224
assert.match(key.publicKey, /^ssh-ed25519 AAAA/);225
const pw = await run(window, "return window.chjPlugin.keys.password({ length: 20, upper: true, lower: true, digits: true, special: true, grouped: true });");226
assert.match(pw.password, /^[^-]{4}-[^-]{4}-[^-]{4}-[^-]{4}-$/u);227
});228
await step("Hash & Checksum: SHA-256 of a local file", async () => {229
const window = await pluginWindow(mainWindow, "chj.hash-checksum");230
const file = path.join(root, "hash-e2e.txt");231
fs.writeFileSync(file, "abc");232
dialogQueue.push({ canceled: false, filePaths: [file] });233
const selection = await run(window, "return window.chjPlugin.hashing.selectFiles(false);");234
const job = await run(window, `return window.chjPlugin.hashing.start({ selectionId: ${JSON.stringify(selection.selectionId)}, algorithms: ['sha256'] });`);235
const jobId = JSON.stringify(job.jobId || job.id);236
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");237
assert.equal(done.state, "completed");238
assert.ok(JSON.stringify(done).includes("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));239
});240
await step("Log Viewer: application log, no secrets", async () => {241
const window = await pluginWindow(mainWindow, "chj.log-viewer");242
const log = await run(window, "return window.chjPlugin.logs.read({ maxBytes: 262144 });");243
const text = log.text ?? log.content ?? String(log);244
assert.match(text, /host key trusted/i);245
assert.doesNotMatch(text, /PRIVATE KEY/);246
assert.equal(text.includes(secret), false, "no SSH secret in the log");247
return `${text.length} B`;248
});249
await step("Updates: catalog check", async () => {250
const state = await core("return api.checkForUpdates().then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));");251
assert.ok(state.ok, state.message);252
assert.equal(state.value.status, "idle", "check() reports its result after finishing");253
const after = await core("return api.getUpdateState();");254
assert.equal(after.status, "idle");255
if (after.release?.build_id === after.currentBuildId) assert.ok(after.currentVersion !== after.release.version || after.updateAvailable === false, "same build offered as an update");256
return `version ${after.currentVersion}, current ${after.currentBuildId}, offered ${after.release?.build_id || after.release?.buildId || "none"} (update available: ${after.updateAvailable})`;257
});258
await step("Disconnect and lock: sessions closed, vault locked", async () => {259
assert.equal((await core(`return api.disconnectSsh(${sid});`)).ok, true);260
assert.equal((await core("return api.lockVault();")).unlocked, false);261
assert.equal(JSON.stringify(await core("return api.listSshSessions();")).includes(`"state":"connected"`), false);262
});263
}265
function finish(code) {266
for (const window of BrowserWindow.getAllWindows()) window.destroy();267
const failed = results.filter((result) => !result.ok);268
if (failed.length || code) {269
// The application log holds no secrets (checked by the Log Viewer step); its tail explains failures.270
for (const file of fs.readdirSync(root, { recursive: true }).map(String).filter((name) => name.endsWith(".log"))) {271
try { console.log(`--- ${file} (tail)\n${fs.readFileSync(path.join(root, file), "utf8").split("\n").slice(-60).join("\n")}`); } catch {}272
}273
}274
console.log(`${failed.length || code ? "CHJ_E2E_FAILED" : "CHJ_E2E_OK"}: ${results.length - failed.length}/${results.length} steps passed on ${process.platform}`);275
setTimeout(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} app.exit(failed.length || code ? 1 : 0); }, 300);276
}277
main().then(() => finish(0), (error) => { console.error(error); finish(1); });278
setTimeout(() => { console.error("E2E exceeded 240s"); finish(1); }, 240000).unref();SHA-256
b634e88a665822c6fe2d766c322066e936f681072762cdac1bb4e77344b9c664CH-J Proprietary Software License 1.14 · Źródło jest dostarczane pod CH-J Proprietary Software License 1.14. Jego dostępność nie zmienia warunków licencji ani nie przyznaje dodatkowych praw.
SHA-256
476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6