CH-J Server Managergestion de serveur sur SSH
Menu
Source publiée

CH-J Server Manager

Parcourez les répertoires et les fichiers pour une version spécifique de l'application.

Télécharger la source ZIP
CH-J / Server Manager 377 fichiers
core-20261019T111616Z-160a00bfcddc Ajouté: · Construit:
pluginRuntime.test.js Ajouté: Date non enregistrée 15,2 KB · 251 lignes
Code Télécharger le fichier
1"use strict";
3const test = require("node:test");
4const assert = require("node:assert/strict");
5const { EventEmitter } = require("node:events");
6const { PluginRuntime } = require("../src/main/plugins/pluginRuntime");
8class FakeWindow extends EventEmitter {
9 constructor() {
10 super();
11 let protocolHandler = null;
12 this.protocolRegistrations = 0;
13 this.webContents = new EventEmitter();
14 this.webContents.id = 42;
15 this.webContents.session = {
16 protocol: {
17 isProtocolHandled: () => Boolean(protocolHandler),
18 handle: (scheme, handler) => {
19 assert.equal(scheme, "chj-plugin");
20 protocolHandler = handler;
21 this.protocolRegistrations += 1;
22 }
23 },
24 setPermissionRequestHandler: () => {},
25 setPermissionCheckHandler: () => {}
26 };
27 this.webContents.setWindowOpenHandler = () => {};
28 this.destroyed = false;
29 this.visible = false;
30 this.loadedUrl = null;
31 }
33 isDestroyed() { return this.destroyed; }
34 isMinimized() { return false; }
35 show() { this.visible = true; }
36 hide() { this.visible = false; }
37 focus() { this.focused = true; }
38 loadURL(url) { this.loadedUrl = url; return Promise.resolve(); }
39 close() { this.destroyed = true; this.emit("closed"); }
42test("Core chrome CSS is available to strict self-only plugin CSP without exposing other Core files", async () => {
43 const runtime = new PluginRuntime({ registry: { resolveEntry: () => ({ manifest: { entry: "ui/index.html" }, root: "/missing-plugin" }) } });
44 const response = await runtime.handleRequest({ url: "chj-plugin://chj.key-generator/__chj_core__/window-chrome.css" });
45 assert.equal(response.status, 200);
46 assert.equal(response.headers.get("Content-Type"), "text/css; charset=utf-8");
47 assert.match(await response.text(), /#chj-chrome-collapse/);
48 const denied = await runtime.handleRequest({ url: "chj-plugin://chj.key-generator/__chj_core__/windowChrome.js" });
49 assert.equal(denied.status, 404);
50});
52test("plugin runtime registers the custom protocol in the isolated plugin session", () => {
53 let pluginWindow;
54 let pluginWindowOptions;
55 const windowStates = [];
56 const managerWindow = Object.assign(new EventEmitter(), { destroyed: false, minimized: false, shown: false, focused: false, isDestroyed() { return this.destroyed; }, isMinimized() { return this.minimized; }, restore() { this.minimized = false; }, show() { this.shown = true; }, focus() { this.focused = true; } });
57 const manifest = {
58 id: "chj.system-monitor",
59 name: "System Monitor",
60 version: "0.0.1",
61 entry: "ui/index.html",
62 permissions: ["session.read", "system.metrics.read"]
63 };
64 const runtime = new PluginRuntime({
65 BrowserWindow: class extends FakeWindow { constructor(options) { super(); pluginWindow = this; pluginWindowOptions = options; } },
66 registry: { resolveEntry: () => ({ manifest, root: "/plugin", entryPath: "/plugin/ui/index.html" }) },
67 sessionManager: {},
68 getMainWindow: () => managerWindow,
69 onWindowState: (windows) => windowStates.push(windows),
70 isVaultUnlocked: () => true,
71 preload: "/pluginPreload.js"
72 });
74 runtime.open(manifest.id);
75 assert.equal(pluginWindow.protocolRegistrations, 1);
76 assert.equal(pluginWindow.loadedUrl, "chj-plugin://chj.system-monitor/ui/index.html");
77 assert.equal(pluginWindowOptions.parent, managerWindow);
78 assert.equal(pluginWindowOptions.modal, false);
79 pluginWindow.emit("ready-to-show");
80 pluginWindow.emit("minimize");
81 assert.equal(pluginWindow.visible, false);
82 assert.equal(managerWindow.focused, true);
83 assert.equal(windowStates.at(-1)[0].minimized, true);
84 runtime.open(manifest.id);
85 assert.equal(pluginWindow.protocolRegistrations, 1);
86 assert.equal(pluginWindow.visible, true);
87 assert.equal(runtime.listWindows()[0].minimized, false);
88 managerWindow.minimized = true;
89 managerWindow.focused = false;
90 managerWindow.emit("minimize");
91 assert.equal(pluginWindow.visible, false);
92 assert.equal(managerWindow.minimized, true);
93 assert.equal(managerWindow.focused, false);
94 assert.equal(runtime.listWindows()[0].minimized, true);
95 pluginWindow.close();
96 assert.equal(managerWindow.listenerCount("minimize"), 0);
97});
99test("plugin collapse IPC docks only the sender's window and restores it on reopen", async () => {
100 const handlers = new Map();
101 let pluginWindow;
102 const manifest = { id: "chj.hash-checksum", name: "Hash & Checksum", entry: "ui/index.html", permissions: [] };
103 const runtime = new PluginRuntime({
104 BrowserWindow: class extends FakeWindow { constructor() { super(); pluginWindow = this; } },
105 registry: { resolveEntry: () => ({ manifest, root: "/plugin" }) },
106 isVaultUnlocked: () => true
107 });
108 runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
109 runtime.open(manifest.id);
110 pluginWindow.emit("ready-to-show");
111 const collapse = handlers.get("plugin:window:minimize");
112 await assert.rejects(() => collapse({ sender: { id: 999 } }), { code: "UNTRUSTED_PLUGIN_SENDER" });
113 await collapse({ sender: { id: pluginWindow.webContents.id } });
114 assert.equal(pluginWindow.visible, false);
115 assert.equal(runtime.listWindows()[0].minimized, true);
116 runtime.open(manifest.id);
117 assert.equal(pluginWindow.visible, true);
118 assert.equal(runtime.listWindows()[0].minimized, false);
119});
121test("plugin runtime routes user capabilities by sender identity and manifest permission", async () => {
122 const handlers = new Map();
123 const calls = [];
124 const sudoError = () => Object.assign(new Error("This operation requires the sudo password of the SSH account."), { code: "SUDO_PASSWORD_REQUIRED" });
125 const runtime = new PluginRuntime({
126 BrowserWindow: FakeWindow,
127 registry: {},
128 sessionManager: {},
129 userAdmin: {
130 list: async (sessionId) => { calls.push(["list", sessionId]); return [{ username: "test" }]; },
131 privileges: async (sessionId, options) => { calls.push(["privileges", sessionId, options.refresh]); return { mode: "sudo-password" }; },
132 inventory: async (sessionId, payload) => { calls.push(["inventory", sessionId, payload.privileged]); return { users: [] }; },
133 detail: async (sessionId, payload) => { calls.push(["detail", sessionId, payload.username]); return { username: payload.username }; },
134 manage: async (sessionId, payload) => { calls.push(["manage", sessionId, payload.action]); if (!payload.sudoPassword) throw sudoError(); return { action: payload.action }; },
135 keys: async (sessionId, payload) => { calls.push(["keys", sessionId, payload.action]); return { action: payload.action }; },
136 sshPolicy: async (sessionId, payload) => { calls.push(["sshPolicy", sessionId, payload.username]); return { applied: true }; }
137 },
138 logService: { readTail: async () => ({ text: "forbidden" }) },
139 keyGeneratorService: {},
140 isVaultUnlocked: () => true
141 });
142 runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
143 const window = { isDestroyed: () => false, close: () => {} };
144 runtime.contexts.set(77, { manifest: { permissions: ["users.read", "users.manage"] }, window });
145 runtime.contexts.set(78, { manifest: { permissions: ["users.read"] }, window });
146 const event = { sender: { id: 77 } };
147 assert.deepEqual(await handlers.get("plugin:users:list")(event, { sessionId: "terminal-users" }), [{ username: "test" }]);
148 assert.deepEqual(await handlers.get("plugin:users:privileges")(event, { sessionId: "terminal-users", refresh: true }), { mode: "sudo-password" });
149 await handlers.get("plugin:users:inventory")(event, { sessionId: "terminal-users", privileged: true });
150 await handlers.get("plugin:users:detail")(event, { sessionId: "terminal-users", username: "bob" });
151 assert.deepEqual(await handlers.get("plugin:users:manage")(event, { sessionId: "terminal-users", action: "lock", username: "bob", sudoPassword: "secret" }), { action: "lock" });
152 // Electron drops error.code; users channels keep it in the message so the plugin can show the sudo dialog.
153 await assert.rejects(() => handlers.get("plugin:users:manage")(event, { sessionId: "terminal-users", action: "lock", username: "bob" }), /^Error: SUDO_PASSWORD_REQUIRED: /);
154 await handlers.get("plugin:users:keys")(event, { sessionId: "terminal-users", action: "add" });
155 await handlers.get("plugin:users:sshPolicy")(event, { sessionId: "terminal-users", username: "bob" });
156 assert.deepEqual(calls, [["list", "terminal-users"], ["privileges", "terminal-users", true], ["inventory", "terminal-users", true], ["detail", "terminal-users", "bob"],
157 ["manage", "terminal-users", "lock"], ["manage", "terminal-users", "lock"], ["keys", "terminal-users", "add"], ["sshPolicy", "terminal-users", "bob"]]);
158 const readOnly = { sender: { id: 78 } };
159 for (const channel of ["plugin:users:manage", "plugin:users:keys", "plugin:users:sshPolicy"]) {
160 await assert.rejects(() => handlers.get(channel)(readOnly, { sessionId: "terminal-users" }), { code: "PLUGIN_PERMISSION_DENIED" });
161 }
162 await assert.rejects(() => handlers.get("plugin:logs:read")(event, {}), { code: "PLUGIN_PERMISSION_DENIED" });
163});
165test("plugin runtime routes file capabilities and keeps permissions separate", async () => {
166 const handlers = new Map();
167 const calls = [];
168 const remoteFileService = {
169 list: async (sessionId, path) => { calls.push(["list", sessionId, path]); return [{ name: "a.txt" }]; },
170 readText: async (sessionId, path) => { calls.push(["readText", sessionId, path]); return { text: "ahoj" }; },
171 writeText: async (sessionId, path, text) => { calls.push(["writeText", sessionId, path, text]); return { size: 4 }; },
172 createFile: async () => ({}), mkdir: async () => ({}), rename: async () => ({}), remove: async () => ({}), removeMany: async () => ({}),
173 upload: async () => ({}), download: async () => ({}), downloadMany: async () => ({}), downloadArchive: async () => ({})
174 };
175 const runtime = new PluginRuntime({ BrowserWindow: FakeWindow, registry: {}, sessionManager: {}, remoteFileService, isVaultUnlocked: () => true });
176 runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
177 const window = { isDestroyed: () => false, close: () => {} };
178 runtime.contexts.set(88, { manifest: { permissions: ["files.read"] }, window });
179 const event = { sender: { id: 88 } };
181 assert.deepEqual(await handlers.get("plugin:files:list")(event, { sessionId: "sftp-1", path: "/home/test" }), [{ name: "a.txt" }]);
182 assert.deepEqual(await handlers.get("plugin:files:readText")(event, { sessionId: "sftp-1", path: "/home/test/a.txt" }), { text: "ahoj" });
183 await assert.rejects(() => handlers.get("plugin:files:writeText")(event, { sessionId: "sftp-1", path: "/home/test/a.txt", text: "nově" }), { code: "PLUGIN_PERMISSION_DENIED" });
184 await assert.rejects(() => handlers.get("plugin:files:download")(event, { sessionId: "sftp-1", path: "/home/test/a.txt" }), { code: "PLUGIN_PERMISSION_DENIED" });
185 for (const capability of ["saveText", "cleanupRecovery"]) {
186 await assert.rejects(() => handlers.get(`plugin:files:${capability}`)(event, { sessionId: "sftp-1" }), { code: "PLUGIN_PERMISSION_DENIED" });
187 }
188 assert.deepEqual(calls, [["list", "sftp-1", "/home/test"], ["readText", "sftp-1", "/home/test/a.txt"]]);
189});
191test("editor IPC forwards structured options, owns document handles and protects recovery reads", async () => {
192 const handlers = new Map(), calls = [];
193 const remoteFileService = {};
194 for (const method of ["readText", "saveText", "listRecovery", "readRecovery", "cleanupRecovery", "closeText"]) {
195 remoteFileService[method] = (...args) => { calls.push([method, ...args]); return { ok: true }; };
196 }
197 const runtime = new PluginRuntime({ BrowserWindow: FakeWindow, registry: {}, remoteFileService, isVaultUnlocked: () => true });
198 runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
199 const window = { isDestroyed: () => false };
200 runtime.contexts.set(91, { manifest: { id: "editor", permissions: ["files.read", "files.write"] }, window });
201 runtime.contexts.set(92, { manifest: { id: "other", permissions: [] }, window });
202 const options = { editId: "opaque", sudo: true, sudoPassword: "secret" };
203 await handlers.get("plugin:files:saveText")({ sender: { id: 91 } }, { sessionId: "s", path: "/etc/config", text: "content", options });
204 assert.deepEqual(calls[0], ["saveText", "s", "/etc/config", "content", options, "editor"]);
205 await handlers.get("plugin:files:closeText")({ sender: { id: 91 } }, { editId: "opaque" });
206 assert.deepEqual(calls[1], ["closeText", "opaque", "editor"]);
207 for (const method of ["listRecovery", "readRecovery", "closeText"]) {
208 await assert.rejects(() => handlers.get(`plugin:files:${method}`)({ sender: { id: 92 } }), { code: "PLUGIN_PERMISSION_DENIED" });
209 }
210});
212test("plugin runtime separates NGINX read and manage capabilities", async () => {
213 const handlers = new Map();
214 const calls = [];
215 const sessionManager = {
216 inspectNginx: async (sessionId) => { calls.push(["inspect", sessionId]); return { installed: true }; },
217 readNginxConfig: async () => ({}), dumpNginxConfig: async () => ({}), testNginxConfig: async () => ({ ok: true }),
218 saveNginxConfig: async (sessionId, payload) => { calls.push(["save", sessionId, payload.path]); return { path: payload.path }; },
219 reloadNginx: async () => ({ ok: true })
220 };
221 const runtime = new PluginRuntime({ BrowserWindow: FakeWindow, registry: {}, sessionManager, isVaultUnlocked: () => true });
222 runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
223 const window = { isDestroyed: () => false, close: () => {} };
224 const event = { sender: { id: 99 } };
225 runtime.contexts.set(99, { manifest: { permissions: ["nginx.read"] }, window });
226 assert.deepEqual(await handlers.get("plugin:nginx:inspect")(event, { sessionId: "nginx-1" }), { installed: true });
227 await assert.rejects(() => handlers.get("plugin:nginx:saveConfig")(event, { sessionId: "nginx-1", path: "/etc/nginx/nginx.conf" }), { code: "PLUGIN_PERMISSION_DENIED" });
228 runtime.contexts.set(99, { manifest: { permissions: ["nginx.read", "nginx.manage"] }, window });
229 assert.deepEqual(await handlers.get("plugin:nginx:saveConfig")(event, { sessionId: "nginx-1", path: "/etc/nginx/nginx.conf" }), { path: "/etc/nginx/nginx.conf" });
230 assert.deepEqual(calls, [["inspect", "nginx-1"], ["save", "nginx-1", "/etc/nginx/nginx.conf"]]);
231});
233test("closing a destroyed plugin window releases its context without reading webContents", () => {
234 let pluginWindow;
235 const cleanup = [];
236 const manifest = { id: "chj.hash-checksum", name: "Hash & Checksum", version: "0.0.1", entry: "ui/index.html", permissions: ["local.hash"] };
237 const runtime = new PluginRuntime({
238 BrowserWindow: class extends FakeWindow { constructor() { super(); pluginWindow = this; } },
239 registry: { resolveEntry: () => ({ manifest, root: "/plugin" }) },
240 localHashService: { on: () => {}, cleanupPlugin: (id) => cleanup.push(id) },
241 isVaultUnlocked: () => true
242 });
243 runtime.open(manifest.id);
244 const senderId = pluginWindow.webContents.id;
245 Object.defineProperty(pluginWindow, "webContents", { get() { throw new Error("Object has been destroyed"); } });
246 pluginWindow.destroyed = true;
247 assert.doesNotThrow(() => pluginWindow.emit("closed"));
248 assert.equal(runtime.contexts.has(senderId), false);
249 assert.equal(runtime.windows.has(manifest.id), false);
250 assert.deepEqual(cleanup, [manifest.id]);
251});
SHA-256b7293a3725f31c10390383712d81cb57d54e03fc6545f812e66920daac65f90a

CH-J Proprietary Software License 1.14 · La source est fournie sous CH-J Proprietary Software License 1.14. Sa disponibilité ne modifie pas les termes de la licence ni n'accorde de droits supplémentaires.

Archive SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Fichiers