"use strict"; // End-to-end UI check of the chj.users plugin: real plugin runtime, preload, IPC, SSH session and // sudo flow against a disposable Docker container built from test/fixtures/usersIntegration. // Run: CHJ_USERS_IMAGE=chj-users-it-debian npx electron scripts/smokeUsersUi.js [screenshot-dir] const { app, BrowserWindow, ipcMain, protocol } = require("electron"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync, spawnSync } = require("node:child_process"); const { hardenSession } = require("../src/main/bootstrap/createMainWindow"); const { PluginRegistry } = require("../src/main/plugins/pluginRegistry"); const { PluginRuntime } = require("../src/main/plugins/pluginRuntime"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec"); const { UserAdminService, createUserAdminApi } = require("../src/main/users/userAdminService"); const { KeyGeneratorService } = require("../src/main/keys/keyGeneratorService"); const { WindowsUserAdminService } = require("../src/main/users/windowsUserAdminService"); const { WindowsExecutor } = require("../src/main/sessions/windowsPrivilegedExec"); const { detectPlatform } = require("../src/main/sessions/platformDetect"); const image = process.env.CHJ_USERS_IMAGE || "chj-users-it-debian"; const container = `chj-users-ui-${process.pid}`; const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-users-ui-")); const shots = process.argv.find((arg, index) => index > 1 && !arg.startsWith("-") && !arg.endsWith(".js")) || null; app.setPath("userData", root); protocol.registerSchemesAsPrivileged([{ scheme: "chj-plugin", privileges: { standard: true, secure: true, supportFetchAPI: true } }]); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const errors = []; let window; const js = (source) => window.webContents.executeJavaScript(source); async function until(source, timeout = 20000) { const started = Date.now(); while (Date.now() - started < timeout) { if (await js(`(() => { try { return (${source}); } catch { return false; } })()`)) return; await delay(60); } throw new Error(`UI condition timed out: ${source}\nstatus: ${await js("document.querySelector('#status').textContent")}`); } async function shot(name) { if (!shots) return; fs.mkdirSync(shots, { recursive: true }); fs.writeFileSync(path.join(shots, `${name}.png`), (await window.webContents.capturePage()).toPNG()); } function inContainer(command) { const result = spawnSync("docker", ["exec", container, "sh", "-c", command], { encoding: "utf8" }); return { code: result.status, stdout: result.stdout }; } async function sudo(password = "AdminPass-1") { await until("document.querySelector('#sudoDialog').open"); await js(`document.querySelector('#sudoPassword').value = ${JSON.stringify(password)}; document.querySelector('#sudoForm').requestSubmit();`); } async function confirm() { await until("document.querySelector('#confirmDialog').open"); await js("document.querySelector('#confirmForm').requestSubmit()"); } async function openTab(name) { await js(`document.querySelector('#tab-${name}').click()`); } async function selectUser(name) { await until(`document.querySelector('button[data-username="${name}"]')`); await js(`document.querySelector('button[data-username="${name}"]').click()`); await until(`document.querySelector('#detailTitle').textContent === "${name}" && !document.querySelector('#detailSubtitle').textContent.includes("Načítám")`); } async function run() { execFileSync("docker", ["run", "-d", "--rm", "--name", container, "--sysctl", "net.ipv6.conf.all.disable_ipv6=0", "-p", "127.0.0.1::22", image]); const port = Number(execFileSync("docker", ["port", container, "22/tcp"], { encoding: "utf8" }).trim().split(":").pop()); await delay(1500); await app.whenReady(); hardenSession(); const hostKeys = new Map(); const profileService = { get: () => ({ id: "it", label: "Docker test", host: "127.0.0.1", port, username: "admin", authMethod: "password" }), getHostKey: () => hostKeys.get(port) || null, trustHostKey: (_host, _port, fingerprint) => hostKeys.set(port, fingerprint), getStoredPassword: () => "AdminPass-1", markUsed() {} }; const sessionManager = new SessionManager({ profileService, latencyMonitor: { start: () => () => {} } }); try { await sessionManager.connect({ sessionId: "ui", profileId: "it" }); } catch (error) { sessionManager.trustPending({ sessionId: "ui", profileId: "it", fingerprint: error.fingerprint }); await sessionManager.connect({ sessionId: "ui", profileId: "it" }); } const windowsService = new WindowsUserAdminService({ sessionManager, windowsExecutor: new WindowsExecutor({ sessionManager }) }); const userAdmin = createUserAdminApi(new UserAdminService({ sessionManager, privilegedExecutor: new PrivilegedExecutor({ sessionManager }), windowsService, detectPlatform })); const keyPath = path.join(root, "id_ed25519_ui"); const keyGeneratorService = new KeyGeneratorService({ selectSavePath: async () => ({ canceled: false, path: keyPath }) }); const registry = new PluginRegistry(root, null, { bundledRoot: path.join(__dirname, "../src/main/firstPartyPlugins") }); const runtime = new PluginRuntime({ BrowserWindow, registry, sessionManager, userAdmin, keyGeneratorService, preload: path.join(__dirname, "../src/preload/pluginPreload.js"), getMainWindow: () => null, isVaultUnlocked: () => true }); runtime.registerIpc(ipcMain); runtime.open("chj.users"); window = runtime.windows.get("chj.users"); window.webContents.on("console-message", (event) => { if (event.level === "error") errors.push(event.message); }); window.setSize(1280, 860); await until("document.querySelector('button[data-username=\"admin\"]')"); assert.match(await js("document.querySelector('#privilegeBadge').textContent"), /admin: sudo s heslem/); assert.equal(await js("document.querySelector('#elevatedButton').hidden"), false); assert.equal(await js("document.querySelectorAll('#sudoPassword').length"), 1, "only the dialog has a sudo password field"); await shot("01-readonly-list"); // Wrong password keeps the dialog usable; cancel leaves the plugin working. await js("document.querySelector('#elevatedButton').click()"); await sudo("wrong-password"); await until("!document.querySelector('#sudoError').hidden", 30000); await shot("02-sudo-wrong-password"); await js("document.querySelector('#sudoDialog [data-close]').click()"); await until("document.querySelector('#status').textContent.includes('zrušena')"); await js("document.querySelector('#elevatedButton').click()"); await sudo(); await until("document.querySelector('#status').textContent.includes('Načteno') && document.querySelector('#elevatedButton').hidden"); assert.match(await js("document.querySelector('#usersBody').textContent"), /plné sudo/); await shot("03-privileged-list"); // Create an account through the dialog. await js("document.querySelector('#createButton').click()"); await until("document.querySelector('#createDialog').open"); await js(`document.querySelector('#createUsername').value = 'uitester'; document.querySelector('#createComment').value = 'UI Tester'; document.querySelector('#createPassword').value = 'Ui-Pass-123'; document.querySelector('#createPassword2').value = 'Ui-Pass-123';`); await shot("04-create-dialog"); await js("document.querySelector('#createForm').requestSubmit()"); await sudo(); await until("document.querySelector('#detailTitle').textContent === 'uitester'", 30000); assert.equal(inContainer("getent passwd uitester | cut -d: -f5").stdout.trim(), "UI Tester"); assert.equal(await js("document.querySelector('#createPassword').value"), "", "password fields are cleared"); // Load privileged detail, inspect all tabs. await selectUser("uitester"); await js("Array.from(document.querySelectorAll('#detailNotice button')).find(b => b.textContent.includes('sudo')).click()"); await sudo(); await until("document.querySelector('#detailBadges').textContent.includes('ověřeno se sudo')", 30000); await shot("05-detail-account"); await openTab("groups"); await shot("06-detail-groups"); await openTab("ssh"); assert.match(await js("document.querySelector('#panel-ssh').textContent"), /Skutečně IPv4/); await shot("07-detail-ssh"); // Apply an SSH policy: password off, IPv6 denied. await js(`document.querySelector('input[name="policy-password"][value="no"]').click(); document.querySelector('input[name="policy-ipv6"][value="deny"]').click(); document.querySelector('.policy-form').requestSubmit();`); await confirm(); await sudo(); await until("document.querySelector('#status').textContent.includes('SSH přístup byl změněn')", 60000); assert.match(inContainer("cat /etc/ssh/sshd_config.d/01-chj-users.conf").stdout, /DenyUsers uitester@::\/0[\s\S]*Match User uitester\n\tPasswordAuthentication no/); await until("document.querySelector('button[data-username=\"uitester\"]').closest('tr').textContent.includes('bez IPv6')"); assert.equal(await js("document.querySelector('#panel-account').hidden"), true, "only the selected tab panel is visible"); await shot("08-ssh-applied"); // Add a key via the dialog, using the local generator (private key saved locally only). await openTab("keys"); await js("Array.from(document.querySelectorAll('#panel-keys button')).find(b => b.textContent.includes('Přidat Ed25519')).click()"); await until("document.querySelector('#keyDialog').open"); await js("document.querySelector('#keyGenerateButton').click(); document.querySelector('#keyGenRun').click();"); await until("document.querySelector('#keyText').value.startsWith('ssh-ed25519 ')"); await shot("09-key-dialog"); await js("document.querySelector('#keyForm').requestSubmit()"); await sudo(); await until("document.querySelector('#panel-keys').textContent.includes('SHA256:')", 30000); assert.ok(fs.existsSync(keyPath), "private key stored locally"); assert.equal(inContainer(`grep -c ssh-ed25519 /home/uitester/.ssh/authorized_keys`).stdout.trim(), "1"); assert.equal(inContainer(`grep -c PRIVATE /home/uitester/.ssh/authorized_keys`).stdout.trim(), "0"); await shot("10-keys"); await openTab("diagnostics"); await shot("11-diagnostics"); // root: protected account, but SSH access (incl. PermitRootLogin) and keys are manageable. await selectUser("root"); assert.equal(await js("Array.from(document.querySelectorAll('#panel-account button')).some(b => b.textContent.includes('Odstranit'))"), false, "no destructive root actions"); assert.match(await js("document.querySelector('#detailNotice').textContent"), /Chráněný systémový účet root/); await js("Array.from(document.querySelectorAll('#detailNotice button')).find(b => b.textContent.includes('sudo')).click()"); await sudo(); await until("document.querySelector('#detailBadges').textContent.includes('ověřeno se sudo')", 30000); await openTab("ssh"); assert.match(await js("document.querySelector('#panel-ssh').textContent"), /PermitRootLogin \(root\)[\s\S]*Jen klíč \(doporučeno\)/); assert.equal(await js("document.querySelector('input[name=\"policy-permitRootLogin\"]').disabled"), false); await shot("11b-root-ssh"); await openTab("keys"); assert.equal(await js("Array.from(document.querySelectorAll('#panel-keys button')).some(b => b.textContent.includes('Přidat Ed25519') && !b.disabled)"), true, "root keys can be added"); await shot("11c-root-keys"); // Protected account: the session user cannot be deleted from the UI. await selectUser("admin"); assert.equal(await js("Array.from(document.querySelectorAll('#panel-account button')).some(b => b.textContent.includes('Odstranit'))"), false); assert.match(await js("document.querySelector('#detailNotice').textContent"), /Tímto účtem je CH-J právě připojeno/); // Delete with typed confirmation. await selectUser("uitester"); await openTab("account"); await js("Array.from(document.querySelectorAll('#panel-account button')).find(b => b.textContent.includes('Odstranit')).click()"); await until("document.querySelector('#confirmDialog').open"); await js("document.querySelector('#confirmForm').requestSubmit()"); assert.equal(await js("document.querySelector('#confirmDialog').open"), true, "typed confirmation required"); await js("document.querySelector('#confirmTyped').value = 'uitester'; document.querySelector('#confirmForm').requestSubmit();"); await sudo(); await until("!document.querySelector('button[data-username=\"uitester\"]')", 60000); assert.equal(inContainer("getent passwd uitester").code, 2); assert.equal(inContainer("test -e /etc/ssh/sshd_config.d/01-chj-users.conf").code, 1, "SSH policy of the deleted account removed"); await shot("12-after-delete"); assert.deepEqual(errors, [], "no renderer errors"); console.log("chj.users UI smoke passed"); } run().then(() => { cleanup(); app.exit(0); }, (error) => { console.error(error); cleanup(); app.exit(1); }); function cleanup() { try { execFileSync("docker", ["rm", "-f", container], { stdio: "ignore" }); } catch {} fs.rmSync(root, { recursive: true, force: true }); }