mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||
),
|
||||
);
|
||||
const setCalls: Array<[string, string, string, string, string]> = [];
|
||||
const set = vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
setCalls.push([pluginId, scope, scopeId, key, value]);
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
@@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => {
|
||||
});
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
@@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||
decryptSystemSecret: async (stored: string) => stored,
|
||||
}));
|
||||
|
||||
const registryProviders = new Map<string, unknown>();
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => registryProviders.get(key),
|
||||
}));
|
||||
|
||||
const {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
writeHostPluginSettings,
|
||||
applyPluginHostImportSettings,
|
||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||
|
||||
function manifest(id: string, host: unknown): PluginManifest {
|
||||
@@ -80,7 +99,10 @@ const DOCKER = manifest("docker", {
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
loaded.length = 0;
|
||||
setCalls.length = 0;
|
||||
registryProviders.clear();
|
||||
getAllForScopeIds.mockClear();
|
||||
set.mockClear();
|
||||
});
|
||||
|
||||
describe("loadHostPluginSettings", () => {
|
||||
@@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => {
|
||||
expect(await withHostPluginSettings(host)).toBe(host);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeHostPluginSettings", () => {
|
||||
it("JSON-stringifies each value under the plugin's own namespace", async () => {
|
||||
await writeHostPluginSettings("docker", 7, {
|
||||
enableDocker: true,
|
||||
socketPath: null,
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
["docker", "host", "7", "enableDocker", "true"],
|
||||
["docker", "host", "7", "socketPath", "null"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a field whose value is undefined", async () => {
|
||||
await writeHostPluginSettings("docker", 7, {
|
||||
enableDocker: true,
|
||||
socketPath: undefined,
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPluginHostImportSettings", () => {
|
||||
it("runs every enabled plugin's registered normalizer and writes what it returns", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
loaded.push({
|
||||
id: "web-endpoint",
|
||||
manifest: manifest("web-endpoint", {
|
||||
enableKey: "enableWebUi",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
||||
}),
|
||||
state: "active",
|
||||
});
|
||||
registryProviders.set("docker.hostImportNormalizer", () => ({
|
||||
enableDocker: true,
|
||||
}));
|
||||
registryProviders.set("web-endpoint.hostImportNormalizer", () => null);
|
||||
|
||||
await applyPluginHostImportSettings(7, { enableDocker: true });
|
||||
|
||||
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
||||
});
|
||||
|
||||
it("skips a plugin with no registered normalizer", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
|
||||
await applyPluginHostImportSettings(7, {});
|
||||
|
||||
expect(setCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("logs and continues past a normalizer that throws", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
loaded.push({
|
||||
id: "web-endpoint",
|
||||
manifest: manifest("web-endpoint", {
|
||||
enableKey: "enableWebUi",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
||||
}),
|
||||
state: "active",
|
||||
});
|
||||
registryProviders.set("docker.hostImportNormalizer", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
registryProviders.set("web-endpoint.hostImportNormalizer", () => ({
|
||||
enableWebUi: true,
|
||||
}));
|
||||
|
||||
await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined();
|
||||
expect(setCalls).toEqual([
|
||||
["web-endpoint", "host", "7", "enableWebUi", "true"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { createIsolatedWindows } = createRequire(import.meta.url)(
|
||||
"../../../../electron/isolated-window.cjs",
|
||||
);
|
||||
const created: FakeWindow[] = [];
|
||||
class FakeWindow extends EventEmitter {
|
||||
options: Record<string, unknown>;
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
mainFrame: {},
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
setMenu = vi.fn();
|
||||
loadURL = vi.fn().mockResolvedValue(undefined);
|
||||
constructor(options: Record<string, unknown>) {
|
||||
super();
|
||||
this.options = options;
|
||||
created.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
let main: FakeWindow;
|
||||
type TestSession = EventEmitter & {
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
|
||||
clearStorageData: ReturnType<typeof vi.fn>;
|
||||
clearCache: ReturnType<typeof vi.fn>;
|
||||
closeAllConnections: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let sessions: TestSession[];
|
||||
let fromPartition: ReturnType<typeof vi.fn>;
|
||||
let manager: ReturnType<typeof createIsolatedWindows>;
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
sessions = [];
|
||||
main = new FakeWindow({});
|
||||
created.length = 0;
|
||||
fromPartition = vi.fn(() => {
|
||||
const session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
manager = createIsolatedWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => main,
|
||||
});
|
||||
});
|
||||
describe("isolated windows", () => {
|
||||
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
await manager.open({ url: "https://service.test" });
|
||||
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
||||
fromPartition.mock.calls[1][0],
|
||||
);
|
||||
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
|
||||
expect(created[0].options.webPreferences).toEqual({
|
||||
session: sessions[0],
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
});
|
||||
});
|
||||
it("uses a caller-supplied partition when given one", async () => {
|
||||
await manager.open({ url: "https://service.test", partition: "fixed" });
|
||||
expect(fromPartition.mock.calls[0][0]).toBe("fixed");
|
||||
});
|
||||
it("rejects non-web destinations before creating a session", async () => {
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@service.test",
|
||||
]) {
|
||||
await expect(manager.open({ url })).rejects.toThrow();
|
||||
}
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses when there is no window to attach to", async () => {
|
||||
manager = createIsolatedWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => null,
|
||||
});
|
||||
await expect(manager.open({ url: "https://service.test" })).rejects.toThrow(
|
||||
/No window/,
|
||||
);
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
const win = created[0],
|
||||
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||
expect(
|
||||
open({ url: "https://login.test" }).overrideBrowserWindowOptions
|
||||
.webPreferences.session,
|
||||
).toBe(sessions[0]);
|
||||
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
|
||||
const navigation = { preventDefault: vi.fn() };
|
||||
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
|
||||
expect(navigation.preventDefault).toHaveBeenCalled();
|
||||
const callback = vi.fn();
|
||||
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
|
||||
{ url: "file:///tmp/secret" },
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
||||
});
|
||||
it("limits invalid certificates to the opted-in origin, including its port", async () => {
|
||||
await manager.open({
|
||||
url: "https://service.test:8443",
|
||||
ignoreCert: true,
|
||||
});
|
||||
const e = { preventDefault: vi.fn() },
|
||||
callback = vi.fn();
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:8443/path",
|
||||
callback,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(callback).toHaveBeenLastCalledWith(true);
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:9443",
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenLastCalledWith(false);
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
main.webContents,
|
||||
"https://service.test:8443",
|
||||
callback,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("closes login popups and clears session data when the endpoint closes", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
const root = created[0],
|
||||
popup = new FakeWindow({});
|
||||
root.webContents.emit("did-create-window", popup);
|
||||
root.destroy();
|
||||
expect(popup.isDestroyed()).toBe(true);
|
||||
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
|
||||
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and
|
||||
* the call only reaches Electron's main process when the backend is actually
|
||||
* running embedded in it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const bridge = vi.hoisted(() => ({
|
||||
available: false,
|
||||
requestFromElectronMain: vi.fn(async () => ({ success: true as const })),
|
||||
}));
|
||||
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
|
||||
isElectronIpcAvailable: () => bridge.available,
|
||||
requestFromElectronMain: bridge.requestFromElectronMain,
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = manifestFor(pluginId, capabilities);
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
bridge.available = false;
|
||||
bridge.requestFromElectronMain.mockClear();
|
||||
});
|
||||
|
||||
describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
it("refuses without desktop:window, before reaching Electron", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when granted but not declared in the manifest", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app even with the capability", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = false;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop app/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
const request = { url: "https://example.test", title: "Example" };
|
||||
await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"open-isolated-window",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isElectronIpcAvailable,
|
||||
requestFromElectronMain,
|
||||
} from "../../utils/electron-ipc-bridge.js";
|
||||
|
||||
const originalEnv = process.env.ELECTRON_EMBEDDED;
|
||||
const originalSend = process.send;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = originalEnv;
|
||||
process.send = originalSend;
|
||||
});
|
||||
|
||||
describe("isElectronIpcAvailable", () => {
|
||||
it("is false outside the embedded desktop backend", () => {
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
process.send = (() => true) as typeof process.send;
|
||||
expect(isElectronIpcAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when there is no fork IPC channel", () => {
|
||||
process.send = undefined;
|
||||
expect(isElectronIpcAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when embedded with a live channel", () => {
|
||||
process.send = (() => true) as typeof process.send;
|
||||
expect(isElectronIpcAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFromElectronMain", () => {
|
||||
it("refuses when Electron IPC is unavailable", async () => {
|
||||
process.send = undefined;
|
||||
await expect(
|
||||
requestFromElectronMain("open-isolated-window", {}),
|
||||
).rejects.toThrow(/desktop app/);
|
||||
});
|
||||
|
||||
// The module attaches its "message" listener to the real process.on only
|
||||
// once, the first time requestFromElectronMain actually reaches that point
|
||||
// (ensureListening's module-scoped flag mirrors how a real forked
|
||||
// process's IPC channel is a single persistent thing it listens on for the
|
||||
// process lifetime). vi.spyOn(process, "on") does not reliably intercept
|
||||
// that call in this environment, so this replaces process.on directly for
|
||||
// the one call the module makes and hands the captured listener a plain
|
||||
// function this file can invoke to simulate a message from main -
|
||||
// simpler and more direct than getting a spy to survive across a Node
|
||||
// global.
|
||||
let deliver: (msg: unknown) => void = () => {};
|
||||
|
||||
beforeAll(() => {
|
||||
const originalOn = process.on.bind(process);
|
||||
process.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === "message") {
|
||||
deliver = listener;
|
||||
return process;
|
||||
}
|
||||
return originalOn(event, listener);
|
||||
}) as typeof process.on;
|
||||
});
|
||||
|
||||
let sent: unknown[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
process.send = ((msg: unknown) => {
|
||||
sent.push(msg);
|
||||
return true;
|
||||
}) as typeof process.send;
|
||||
});
|
||||
|
||||
it("resolves with main's response, matched by request id", async () => {
|
||||
const pending = requestFromElectronMain<{ success: true }>(
|
||||
"open-isolated-window",
|
||||
{ url: "https://example.test" },
|
||||
);
|
||||
const [message] = sent as Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
}>;
|
||||
expect(message).toMatchObject({
|
||||
type: "backend-request",
|
||||
channel: "open-isolated-window",
|
||||
payload: { url: "https://example.test" },
|
||||
});
|
||||
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: { success: true },
|
||||
});
|
||||
await expect(pending).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("rejects with main's error", async () => {
|
||||
const pending = requestFromElectronMain("open-isolated-window", {});
|
||||
const [message] = sent as Array<{ id: string }>;
|
||||
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: "refused",
|
||||
});
|
||||
await expect(pending).rejects.toThrow(/refused/);
|
||||
});
|
||||
|
||||
it("ignores a response for a different request id", async () => {
|
||||
const pending = requestFromElectronMain("open-isolated-window", {});
|
||||
const [message] = sent as Array<{ id: string }>;
|
||||
|
||||
deliver({ type: "backend-response", id: "other", ok: true, result: {} });
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: { success: true },
|
||||
});
|
||||
await expect(pending).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The web endpoint host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with web endpoints on and a saved
|
||||
* config must keep both once the ssh_data columns are dropped. Running it
|
||||
* twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_web_ui: boolean;
|
||||
web_ui_config: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runWebEndpointSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
const endpoint = {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runWebEndpointSettingsMigration", () => {
|
||||
it("moves a host's switch and saved config into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_web_ui: true,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableWebUi")).toBe(true);
|
||||
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
|
||||
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a disabled host's saved config and its disabled switch", async () => {
|
||||
hostRows.push({
|
||||
id: 3,
|
||||
enable_web_ui: false,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
|
||||
await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(storedValue(3, "enableWebUi")).toBe(false);
|
||||
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
|
||||
});
|
||||
|
||||
it("stores an unreadable config as null rather than failing", async () => {
|
||||
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "webUiConfig")).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
|
||||
|
||||
await runWebEndpointSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({
|
||||
id: 5,
|
||||
enable_web_ui: true,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
"5",
|
||||
"enableWebUi",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableWebUi")).toBe(false);
|
||||
expect(storedValue(5, "webUiConfig")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user