mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -105,15 +105,10 @@ describe("normalizeProtocolEnableFields", () => {
|
||||
expect(normalizeProtocolEnableFields({ name: "server" })).toEqual({});
|
||||
});
|
||||
|
||||
it("converts explicitly provided protocol booleans to database integers", () => {
|
||||
it("converts an explicitly provided SSH switch to a database integer", () => {
|
||||
expect(
|
||||
normalizeProtocolEnableFields({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: undefined,
|
||||
enableTelnet: true,
|
||||
}),
|
||||
).toEqual({ enableSsh: 1, enableRdp: 0, enableTelnet: 1 });
|
||||
normalizeProtocolEnableFields({ enableSsh: true, enableRdp: false }),
|
||||
).toEqual({ enableSsh: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,14 +169,13 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.connectionType).toBe("ssh");
|
||||
expect(host.port).toBe(22);
|
||||
expect(host.enableSsh).toBe(true);
|
||||
expect(host.enableRdp).toBe(false);
|
||||
});
|
||||
|
||||
it("infers rdp from enableRdp and uses default rdp port", () => {
|
||||
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("rdp");
|
||||
expect(host.port).toBe(3389);
|
||||
expect(host.enableRdp).toBe(true);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("honors an explicit port over protocol defaults", () => {
|
||||
@@ -321,21 +315,15 @@ describe("transformHostResponse", () => {
|
||||
expect(result.jumpHosts).toEqual([]);
|
||||
});
|
||||
|
||||
it("infers protocol flags for a migrated non-ssh host", () => {
|
||||
const result = transformHostResponse({
|
||||
connectionType: "rdp",
|
||||
enableSsh: true,
|
||||
});
|
||||
expect(result.enableSsh).toBe(false);
|
||||
expect(result.enableRdp).toBe(true);
|
||||
it("passes the stored SSH switch through", () => {
|
||||
// The remote desktop migration corrects connection_type-only hosts once.
|
||||
expect(transformHostResponse({ enableSsh: false }).enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("applies default protocol ports", () => {
|
||||
it("applies the default SSH port", () => {
|
||||
const result = transformHostResponse({ port: 22 });
|
||||
expect(result.sshPort).toBe(22);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.vncPort).toBe(5900);
|
||||
expect(result.telnetPort).toBe(23);
|
||||
expect(result).not.toHaveProperty("rdpPort");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,8 +414,6 @@ describe("sanitizeHostForRecipient", () => {
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
expect(result.ip).toBe("10.0.0.42");
|
||||
expect(result.enableRdp).toBe(true);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.rdpAuthType).toBe("none");
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ctx.credentials.resolveHostProtocol: credentials:read is checked and every
|
||||
* call audited, the owner gets their stored or credential-mode login, and a
|
||||
* shared recipient only ever sees what core's sharing rules hand them.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
credentials: new Map<number, { username: string; password: string }>(),
|
||||
canConnect: true,
|
||||
resolution: null as Record<string, unknown> | null,
|
||||
resolverCalls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findCredentialByIdForUser: async (id: number) =>
|
||||
state.credentials.get(id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => ({ hasAccess: state.canConnect }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
|
||||
resolveRecipientSharedHostAuthentication: async (
|
||||
host: Record<string, unknown>,
|
||||
) => {
|
||||
state.resolverCalls.push(host);
|
||||
return state.resolution;
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
const baseHost = {
|
||||
id: 7,
|
||||
userId: "owner",
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "sshuser",
|
||||
password: "ssh-secret",
|
||||
jumpHosts: JSON.stringify([{ hostId: 3 }]),
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "rdp-secret",
|
||||
rdpDomain: "CORP",
|
||||
rdpAuthType: null,
|
||||
rdpCredentialId: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.hosts.clear();
|
||||
state.credentials.clear();
|
||||
state.canConnect = true;
|
||||
state.resolution = null;
|
||||
state.resolverCalls.length = 0;
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
});
|
||||
|
||||
describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
it("refuses without credentials:read and audits the refusal", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
),
|
||||
).rejects.toThrow(/credentials:read/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the owner's stored login and audits it", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
);
|
||||
expect(target).toEqual({
|
||||
host: {
|
||||
id: 7,
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
ownerUserId: "owner",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
},
|
||||
shared: false,
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "rdp-secret",
|
||||
domain: "CORP",
|
||||
},
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.hosts.set(7, {
|
||||
...baseHost,
|
||||
vncCredentialId: 11,
|
||||
vncAuthType: "credential",
|
||||
});
|
||||
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "vnc"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "viewer",
|
||||
password: "vnc-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("never hands a shared recipient the owner's raw secrets", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = {
|
||||
source: "owner-shared",
|
||||
authType: "direct",
|
||||
secret: { username: "shared", password: "snap", domain: null },
|
||||
};
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toMatchObject({
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
});
|
||||
expect(state.resolverCalls[0]).toMatchObject({
|
||||
password: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a recipient with nothing shared an empty login", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = { source: "required" };
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "telnet"),
|
||||
);
|
||||
expect(target?.auth.username).toBe("");
|
||||
expect(target?.auth.password).toBe("");
|
||||
});
|
||||
|
||||
it("returns null without connect access or for a missing host", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.canConnect = false;
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(99, "rdp"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
});
|
||||
});
|
||||
@@ -121,3 +121,50 @@ describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const request = { host: "win.example.test", port: 3389, username: "bob" };
|
||||
|
||||
it("refuses without desktop:window", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
/desktop:window/,
|
||||
);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(false);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(true);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"launch-native-rdp",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const h = vi.hoisted(() => ({
|
||||
activities: [] as Array<Record<string, unknown>>,
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -110,6 +111,14 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
row: Record<string, unknown>,
|
||||
) => {
|
||||
h.importRows.push({ hostId, row });
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
@@ -325,6 +334,7 @@ describe("ctx.hosts", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
enableRdp: true,
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
@@ -332,6 +342,11 @@ describe("ctx.hosts", () => {
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(h.created).toHaveLength(1);
|
||||
// Another plugin's fields go through its import normalizer.
|
||||
expect(h.importRows.at(-1)).toMatchObject({
|
||||
hostId: 99,
|
||||
row: { enableRdp: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a host once granted hosts:write", async () => {
|
||||
|
||||
@@ -83,6 +83,24 @@ describe("contributes.settings", () => {
|
||||
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||
});
|
||||
|
||||
it("accepts a hidden field and rejects a non-boolean hidden", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
fields: [{ key: "a", type: "number", labelKey: "k", hidden: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", hidden: "yes" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.join(" ")).toContain("hidden must be a boolean");
|
||||
});
|
||||
|
||||
it("rejects an unknown scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const db = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => db.current,
|
||||
}));
|
||||
|
||||
const { selectRows, runStatement } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
|
||||
describe("raw-rows", () => {
|
||||
beforeEach(() => {
|
||||
db.current = {};
|
||||
});
|
||||
|
||||
it("uses all() and run() on SQLite", async () => {
|
||||
const run = vi.fn();
|
||||
db.current = { all: async () => [{ id: 1 }], run };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 1 }]);
|
||||
await runStatement(sql`UPDATE x SET y = 1`);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads rows from node-postgres's { rows }", async () => {
|
||||
db.current = { execute: async () => ({ rows: [{ id: 2 }] }) };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 2 }]);
|
||||
});
|
||||
|
||||
it("reads rows from mysql2's [rows, fields]", async () => {
|
||||
const execute = vi.fn(async () => [[{ id: 3 }], []]);
|
||||
db.current = { execute };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 3 }]);
|
||||
await runStatement(sql`UPDATE x SET y = 1`);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* The Remote Desktop settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch and guacd address, each
|
||||
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
|
||||
* through into the plugin's own settings, whatever shape an old host had.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
|
||||
statements: [] as string[],
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks?: unknown[] }) => {
|
||||
const text = JSON.stringify(query.queryChunks ?? query);
|
||||
return text.includes("user_preferences") ? state.userRows : state.hostRows;
|
||||
},
|
||||
runStatement: async (query: unknown) => {
|
||||
state.statements.push(JSON.stringify(query));
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
|
||||
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
function host(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
port: 22,
|
||||
connection_type: "ssh",
|
||||
enable_rdp: 0,
|
||||
enable_vnc: 0,
|
||||
enable_telnet: 0,
|
||||
rdp_port: 3389,
|
||||
vnc_port: 5900,
|
||||
telnet_port: 23,
|
||||
rdp_security: null,
|
||||
rdp_ignore_cert: 0,
|
||||
security: null,
|
||||
ignore_cert: 0,
|
||||
guacamole_config: null,
|
||||
enable_terminal_toolbar: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows.length = 0;
|
||||
state.hostRows.length = 0;
|
||||
state.userRows.length = 0;
|
||||
state.statements.length = 0;
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runRemoteDesktopSettingsMigration", () => {
|
||||
it("moves the admin switch and guacd address", async () => {
|
||||
state.coreSettings.set("guac_enabled", "false");
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
|
||||
expect(value("admin", null, "enabled")).toBe(false);
|
||||
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
|
||||
});
|
||||
|
||||
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
|
||||
state.hostRows.push(
|
||||
host({
|
||||
id: 7,
|
||||
enable_rdp: 1,
|
||||
enable_vnc: 0,
|
||||
rdp_port: 3390,
|
||||
rdp_security: "nla",
|
||||
rdp_ignore_cert: 1,
|
||||
guacamole_config: JSON.stringify({ "color-depth": 24 }),
|
||||
enable_terminal_toolbar: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(1);
|
||||
expect(value("host", "7", "enableRdp")).toBe(true);
|
||||
expect(value("host", "7", "enableVnc")).toBeUndefined();
|
||||
expect(value("host", "7", "rdpPort")).toBe(3390);
|
||||
expect(value("host", "7", "rdpSecurity")).toBe("nla");
|
||||
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
|
||||
expect(value("host", "7", "guacamoleConfig")).toEqual({
|
||||
"color-depth": 24,
|
||||
});
|
||||
expect(value("host", "7", "enableToolbar")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a plain SSH host alone", async () => {
|
||||
state.hostRows.push(host({ id: 3 }));
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
expect(result.hostsMoved).toBe(0);
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads real booleans too, as Postgres returns them", async () => {
|
||||
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(value("host", "8", "enableVnc")).toBe(true);
|
||||
expect(value("host", "8", "vncPort")).toBe(5901);
|
||||
});
|
||||
|
||||
it("moves each user's RDP defaults", async () => {
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({
|
||||
colorDepth: 24,
|
||||
resizeMethod: "reconnect",
|
||||
disableCopy: true,
|
||||
enableWallpaper: false,
|
||||
}),
|
||||
});
|
||||
state.userRows.push({ user_id: "u2", rdp_defaults: null });
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.usersMoved).toBe(1);
|
||||
expect(value("user", "u1", "colorDepth")).toBe("24");
|
||||
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
|
||||
expect(value("user", "u1", "disableCopy")).toBe("on");
|
||||
expect(value("user", "u1", "enableWallpaper")).toBe("off");
|
||||
});
|
||||
|
||||
it("changes nothing when run twice", async () => {
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
|
||||
});
|
||||
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
const firstRun = JSON.stringify(state.pluginRows);
|
||||
// Changed after the first run: must not be overwritten by the old values.
|
||||
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
|
||||
const second = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.usersMoved).toBe(0);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(value("host", "7", "rdpPort")).toBe(4000);
|
||||
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostSettingsFromRow", () => {
|
||||
it("turns a connection_type-only host into its protocol switch and port", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
|
||||
),
|
||||
).toEqual({ enableVnc: true, vncPort: 5905 });
|
||||
});
|
||||
|
||||
it("folds the older security and ignore_cert columns in", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({
|
||||
enable_rdp: 1,
|
||||
rdp_security: null,
|
||||
security: "tls",
|
||||
rdp_ignore_cert: null,
|
||||
ignore_cert: 1,
|
||||
}),
|
||||
),
|
||||
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user