mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move terminal settings, look and ssh tools into ssh-terminal plugin
This commit is contained in:
1 parent
ffa872ebd3
commit
d9bba10612
242 files changed
+25071
-7207
No files matched your search
@@ -425,3 +425,57 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformHostResponse terminal fields", () => {
|
||||
const row = (extra: Record<string, unknown>) =>
|
||||
transformHostResponse({ id: 1, tags: "", ...extra });
|
||||
|
||||
it("sends the SSH options and core's own terminalConfig keys only", () => {
|
||||
const host = row({
|
||||
sshOptions: JSON.stringify({ keepaliveInterval: 20 }),
|
||||
terminalConfig: JSON.stringify({
|
||||
startupSnippetId: 3,
|
||||
theme: "nord",
|
||||
keepaliveInterval: 5,
|
||||
}),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 20 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 20,
|
||||
startupSnippetId: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the options out of terminal_config before the boot copy", () => {
|
||||
const host = row({
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({ agentForwarding: true }),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ agentForwarding: true });
|
||||
});
|
||||
|
||||
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
|
||||
const host = row({
|
||||
sudoPassword: null,
|
||||
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
|
||||
});
|
||||
const stripped = stripSensitiveFields(host);
|
||||
expect(stripped.hasSudoPassword).toBe(true);
|
||||
expect(stripped).not.toHaveProperty("sudoPassword");
|
||||
expect(JSON.stringify(stripped)).not.toContain("legacy");
|
||||
});
|
||||
|
||||
it("hides the owner's agent socket from a shared recipient", () => {
|
||||
const shared = sanitizeHostForRecipient(
|
||||
row({
|
||||
sshOptions: JSON.stringify({
|
||||
agentSocketPath: "/run/agent",
|
||||
keepaliveInterval: 9,
|
||||
}),
|
||||
}),
|
||||
"edit",
|
||||
);
|
||||
expect(shared.sshOptions).toEqual({ keepaliveInterval: 9 });
|
||||
expect(shared.terminalConfig).toEqual({ keepaliveInterval: 9 });
|
||||
});
|
||||
});
|
||||
@@ -415,6 +415,22 @@ describe("share levels, legacy fields and generic writes", () => {
|
||||
expect(host.name).toBe("web");
|
||||
});
|
||||
|
||||
it("adds a plugin's keys to an object core already sends, never over core's", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
registryProviders.set("docker.hostPayloadLegacy", () => ({
|
||||
terminalConfig: { theme: "nord", startupSnippetId: 99 },
|
||||
}));
|
||||
const host: Record<string, unknown> = {
|
||||
id: 2,
|
||||
terminalConfig: { startupSnippetId: 1 },
|
||||
};
|
||||
attachHostPluginSettings(
|
||||
[host],
|
||||
new Map([[2, { docker: { enableDocker: true } }]]),
|
||||
);
|
||||
expect(host.terminalConfig).toEqual({ theme: "nord", startupSnippetId: 1 });
|
||||
});
|
||||
|
||||
it("imports an export's plugin values, leaving secrets out", async () => {
|
||||
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
|
||||
await applyPluginHostImportSettings(9, {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stored: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async () => state.stored,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mergeStoredTerminalFields, parseTerminalConfig } =
|
||||
await import("../../../database/routes/host-terminal-fields.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stored = {
|
||||
terminalConfig: JSON.stringify({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 1,
|
||||
}),
|
||||
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
|
||||
};
|
||||
});
|
||||
|
||||
async function merge(
|
||||
hostData: Record<string, unknown>,
|
||||
isOwner = true,
|
||||
sshOptions?: string,
|
||||
) {
|
||||
const row: Record<string, unknown> = {
|
||||
terminalConfig: "replaced wholesale",
|
||||
...(sshOptions !== undefined ? { sshOptions } : {}),
|
||||
};
|
||||
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
|
||||
return { error, row };
|
||||
}
|
||||
|
||||
describe("parseTerminalConfig", () => {
|
||||
it("reads objects and JSON, and nothing else", () => {
|
||||
expect(parseTerminalConfig({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(parseTerminalConfig('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(parseTerminalConfig("nope")).toBeNull();
|
||||
expect(parseTerminalConfig([1])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStoredTerminalFields", () => {
|
||||
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
|
||||
const { error, row } = await merge({
|
||||
terminalConfig: { startupSnippetId: 4 },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(JSON.parse(row.terminalConfig as string)).toEqual({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves terminal_config alone when the payload has none", async () => {
|
||||
const { row } = await merge({});
|
||||
expect(row).not.toHaveProperty("terminalConfig");
|
||||
});
|
||||
|
||||
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
|
||||
const { row } = await merge({ sudoPassword: "" });
|
||||
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
|
||||
"sudoPassword",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the owner's private keys through a shared editor's save", async () => {
|
||||
const { row } = await merge(
|
||||
{ terminalConfig: { startupSnippetId: 2 } },
|
||||
false,
|
||||
JSON.stringify({ keepaliveInterval: 9 }),
|
||||
);
|
||||
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 2,
|
||||
});
|
||||
expect(JSON.parse(row.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 9,
|
||||
agentSocketPath: "/owner/agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a terminalConfig it cannot read", async () => {
|
||||
const { error } = await merge({ terminalConfig: "not json" });
|
||||
expect(error).toBe("Invalid terminal config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The admin host defaults. The terminal half moved to the ssh-terminal
|
||||
* plugin's new-host settings; its keys stay in the stored row for the boot
|
||||
* copy and a downgrade, but are never served or overwritten here.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
admin: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
getGlobalLogLevel: () => "info",
|
||||
setGlobalLogLevel: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/analytics.js", () => ({
|
||||
getTelemetryEnvOverride: () => null,
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
id,
|
||||
username: "admin",
|
||||
isAdmin: state.admin,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerUserSettingsRoutes } =
|
||||
await import("../../../database/routes/user-settings-routes.js");
|
||||
|
||||
const authenticate: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "u1";
|
||||
next();
|
||||
};
|
||||
|
||||
function app() {
|
||||
const router = express.Router();
|
||||
registerUserSettingsRoutes(router, authenticate);
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use("/users", router);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.admin = true;
|
||||
});
|
||||
|
||||
describe("/users/host-defaults", () => {
|
||||
it("serves core's defaults without the terminal keys the plugin took over", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
});
|
||||
const response = await request(app()).get("/users/host-defaults");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ useSocks5: true });
|
||||
});
|
||||
|
||||
it("keeps the stored terminal keys when an admin saves core's defaults", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
});
|
||||
const response = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(state.settings.host_defaults)).toEqual({
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
useSocks5: false,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a user who is not an admin", async () => {
|
||||
state.admin = false;
|
||||
const response = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: true });
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
+32
-1
@@ -186,6 +186,20 @@ const HOST_ROWS: Row[] = [
|
||||
enable_docker: 1,
|
||||
docker_config: JSON.stringify({ runtime: "podman" }),
|
||||
enable_ai_assistant: 1,
|
||||
// Its own terminal look, behavior and SSH options, all in one JSON.
|
||||
terminal_config: JSON.stringify({
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
}),
|
||||
mac_address: "AA:BB:CC:DD:EE:04",
|
||||
wol_broadcast_address: "10.4.0.255",
|
||||
stats_config: JSON.stringify({
|
||||
@@ -198,6 +212,8 @@ const HOST_ROWS: Row[] = [
|
||||
}),
|
||||
}),
|
||||
host(HOSTS.key, "d4-key", {
|
||||
// Behavior only: this host kept following the user's look.
|
||||
terminal_config: JSON.stringify({ passwordPromptAutoFill: false }),
|
||||
key: PRIVATE_KEY,
|
||||
key_password: "d4-key-pass",
|
||||
key_type: "ssh-ed25519",
|
||||
@@ -362,6 +378,15 @@ export const ROWS: Record<string, Row[]> = {
|
||||
resizeMethod: "reconnect",
|
||||
enableDrive: true,
|
||||
}),
|
||||
terminal_defaults: JSON.stringify({ fontSize: 18, cursorBlink: false }),
|
||||
custom_themes: JSON.stringify([
|
||||
{
|
||||
id: "d4-theme",
|
||||
name: "d4 theme",
|
||||
colors: { background: "#101010", foreground: "#f0f0f0" },
|
||||
},
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
},
|
||||
],
|
||||
sso_providers: [
|
||||
@@ -811,7 +836,13 @@ export const ROWS: Record<string, Row[]> = {
|
||||
["metrics_history_retention_days", "14"],
|
||||
[
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, enableCommandHistory: false }),
|
||||
JSON.stringify({
|
||||
metricsEnabled: false,
|
||||
enableCommandHistory: false,
|
||||
fontSize: 20,
|
||||
theme: "nord",
|
||||
autoTmux: true,
|
||||
}),
|
||||
],
|
||||
["ai_globally_enabled", "true"],
|
||||
["ai_private_endpoint_allowlist", JSON.stringify(["10.4.0.0/24"])],
|
||||
|
||||
@@ -412,6 +412,9 @@ DROP TABLE `sync_tombstones`;
|
||||
-- ==== core 0061_plugin_settings_scope_enum ====
|
||||
ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','secret') NOT NULL;
|
||||
|
||||
-- ==== core 0062_smooth_speed ====
|
||||
ALTER TABLE `ssh_data` ADD `ssh_options` text;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -432,6 +432,9 @@ CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id
|
||||
-- ==== core 0061_red_lila_cheney ====
|
||||
DROP TABLE "sync_tombstones" CASCADE;
|
||||
|
||||
-- ==== core 0062_curvy_killmonger ====
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -267,7 +267,7 @@ describe("buildConnectConfig per auth type", () => {
|
||||
host({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
terminalConfig: { agentSocketPath: "/tmp/agent.sock" },
|
||||
sshOptions: { agentSocketPath: "/tmp/agent.sock" },
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
@@ -388,7 +388,7 @@ describe("profile defaults", () => {
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
sshOptions: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
|
||||
@@ -307,9 +307,11 @@ describe("resolveHostById", () => {
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("agent");
|
||||
expect(host.sshOptions).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
});
|
||||
expect(host.terminalConfig).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
sudoPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,10 +451,43 @@ describe("resolveHostById", () => {
|
||||
expect(host.password).toBeNull();
|
||||
expect(host.key).toBeNull();
|
||||
expect(host.credentialId).toBeNull();
|
||||
expect(host.terminalConfig).toEqual({
|
||||
theme: "termix",
|
||||
// The owner's legacy sudo password never reaches a recipient.
|
||||
expect(host.terminalConfig).toEqual({ theme: "termix" });
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
|
||||
state.host = baseHost({
|
||||
sudoPassword: null,
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
sudoPassword: "legacy-sudo",
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
}),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sudoPassword).toBe("legacy-sudo");
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the ssh_options column once it is filled", async () => {
|
||||
state.host = baseHost({
|
||||
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
|
||||
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSshOptions,
|
||||
parseSshOptions,
|
||||
sshOptionsForWrite,
|
||||
} from "../../hosts/ssh-options.js";
|
||||
|
||||
describe("parseSshOptions", () => {
|
||||
it("keeps the connection options, typed, and drops everything else", () => {
|
||||
expect(
|
||||
parseSshOptions(
|
||||
JSON.stringify({
|
||||
keepaliveInterval: "30",
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: 1 }, null],
|
||||
theme: "nord",
|
||||
cfAccessClientId: "gone",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads twice-stringified rows and ignores garbage", () => {
|
||||
expect(
|
||||
parseSshOptions(JSON.stringify(JSON.stringify({ keepaliveInterval: 5 }))),
|
||||
).toEqual({ keepaliveInterval: 5 });
|
||||
expect(parseSshOptions("nope")).toEqual({});
|
||||
expect(parseSshOptions(null)).toEqual({});
|
||||
expect(parseSshOptions([1])).toEqual({});
|
||||
});
|
||||
|
||||
it("says whether a value carries any option", () => {
|
||||
expect(hasSshOptions({ theme: "nord" })).toBe(false);
|
||||
expect(hasSshOptions({ agentForwarding: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sshOptionsForWrite", () => {
|
||||
it("stores the payload's own options", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({ sshOptions: { keepaliveInterval: 9, junk: 1 } }),
|
||||
).toBe(JSON.stringify({ keepaliveInterval: 9 }));
|
||||
expect(sshOptionsForWrite({ sshOptions: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("takes them from a 2.8 terminalConfig when the payload has none", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: { keepaliveCountMax: 2, theme: "nord" },
|
||||
}),
|
||||
).toBe(JSON.stringify({ keepaliveCountMax: 2 }));
|
||||
});
|
||||
|
||||
it("leaves the column alone when neither carries any", () => {
|
||||
expect(sshOptionsForWrite({ terminalConfig: { theme: "nord" } })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(sshOptionsForWrite({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,18 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
// The look it saved in terminal_config, which took it off the user's.
|
||||
inheritAppearance: false,
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
|
||||
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
@@ -361,6 +372,9 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTerminalToolbar: true,
|
||||
inheritAppearance: true,
|
||||
autoTmux: false,
|
||||
passwordPromptAutoFill: true,
|
||||
});
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
@@ -390,6 +404,18 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
}
|
||||
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
|
||||
|
||||
// The connection options in terminal_config moved to core's ssh_options.
|
||||
const [options] = await selectRows<{ ssh_options: string | null }>(
|
||||
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
|
||||
);
|
||||
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const target = await asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
|
||||
@@ -439,6 +465,14 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
sessionPersistence: false,
|
||||
commandHistoryForNewHosts: false,
|
||||
imageMaxCount: 7,
|
||||
newHostFontSize: 20,
|
||||
newHostTheme: "nord",
|
||||
newHostAutoTmux: true,
|
||||
});
|
||||
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The terminal's look and behavior moving out of ssh_data.terminal_config and
|
||||
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
|
||||
* second run changes nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
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(() => ({
|
||||
db: null as unknown,
|
||||
rows: [] as Row[],
|
||||
installed: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.rows.find(
|
||||
(row) =>
|
||||
row.pluginId === "ssh-terminal" &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.installed && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
|
||||
state.rows.filter(
|
||||
(row) => row.scope === scope && scopeIds.includes(row.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;
|
||||
else state.rows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
runSshTerminalLookMigration,
|
||||
hostSettingsFromTerminalConfig,
|
||||
userSettingsFromPreferences,
|
||||
} = await import("../../upgrade/ssh-terminal-look-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
state.rows.length = 0;
|
||||
state.installed = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
terminal_defaults TEXT,
|
||||
custom_themes TEXT,
|
||||
command_autocomplete INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function user(id: string, key: string): unknown {
|
||||
const row = find("user", id, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
describe("hostSettingsFromTerminalConfig", () => {
|
||||
it("keeps a host that followed the user's look following it", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig(
|
||||
JSON.stringify({
|
||||
autoTmux: true,
|
||||
localEcho: "on",
|
||||
keepaliveInterval: 30,
|
||||
startupSnippetId: 3,
|
||||
}),
|
||||
),
|
||||
).toEqual({ autoTmux: true, localEcho: "on" });
|
||||
});
|
||||
|
||||
it("opts a host with its own look out of the user's defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig({
|
||||
theme: "Termix Dark",
|
||||
fontSize: "16",
|
||||
cursorStyle: "wobble",
|
||||
bellStyle: "visual",
|
||||
}),
|
||||
).toEqual({
|
||||
inheritAppearance: false,
|
||||
theme: "termix",
|
||||
fontSize: 16,
|
||||
bellStyle: "visual",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing out of garbage", () => {
|
||||
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
|
||||
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userSettingsFromPreferences", () => {
|
||||
it("takes the defaults, saved themes and autocomplete", () => {
|
||||
expect(
|
||||
userSettingsFromPreferences({
|
||||
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
|
||||
custom_themes: JSON.stringify([
|
||||
{ id: "a", name: "Mine", colors: { background: "#000" } },
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
terminalDefaults: { fontSize: 18, theme: "nord" },
|
||||
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
|
||||
commandAutocomplete: true,
|
||||
});
|
||||
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSshTerminalLookMigration", () => {
|
||||
function seed() {
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
|
||||
1,
|
||||
JSON.stringify({
|
||||
theme: "dracula",
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
keepaliveInterval: 30,
|
||||
}),
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run(
|
||||
"u1",
|
||||
JSON.stringify({ cursorBlink: false }),
|
||||
JSON.stringify([
|
||||
{ id: "t", name: "T", colors: { background: "#111" } },
|
||||
]),
|
||||
1,
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run("u2", null, null, null);
|
||||
}
|
||||
|
||||
it("copies each host's terminal keys and each user's terminal preferences", async () => {
|
||||
seed();
|
||||
const result = await runSshTerminalLookMigration();
|
||||
|
||||
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
|
||||
expect(host(1, "inheritAppearance")).toBe(false);
|
||||
expect(host(1, "theme")).toBe("dracula");
|
||||
expect(host(1, "fontSize")).toBe(18);
|
||||
expect(host(1, "autoTmux")).toBe(true);
|
||||
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
|
||||
expect(host(1, "keepaliveInterval")).toBeUndefined();
|
||||
expect(host(2, "passwordPromptAutoFill")).toBe(false);
|
||||
expect(host(2, "inheritAppearance")).toBeUndefined();
|
||||
expect(host(3, "keepaliveInterval")).toBeUndefined();
|
||||
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
|
||||
expect(user("u1", "customThemes")).toHaveLength(1);
|
||||
expect(user("u1", "commandAutocomplete")).toBe(true);
|
||||
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after values were edited", async () => {
|
||||
seed();
|
||||
await runSshTerminalLookMigration();
|
||||
find("host", "1", "fontSize")!.value = JSON.stringify(12);
|
||||
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
|
||||
const before = JSON.stringify(state.rows);
|
||||
|
||||
const second = await runSshTerminalLookMigration();
|
||||
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
|
||||
expect(JSON.stringify(state.rows)).toBe(before);
|
||||
expect(host(1, "fontSize")).toBe(12);
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
seed();
|
||||
state.installed = false;
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
expect(state.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies nothing from a database without the columns", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
state.db = drizzle(sqlite);
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,30 @@ describe("runSshTerminalSettingsMigration", () => {
|
||||
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
|
||||
});
|
||||
|
||||
it("moves the terminal half of the admin host defaults to the new-host settings", async () => {
|
||||
coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
fontFamily: "Fira Code",
|
||||
theme: "Termix Dark",
|
||||
cursorStyle: "wobble",
|
||||
cursorBlink: false,
|
||||
autoTmux: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(admin("newHostFontSize")).toBe(18);
|
||||
expect(admin("newHostFontFamily")).toBe("Fira Code");
|
||||
expect(admin("newHostTheme")).toBe("termix");
|
||||
expect(admin("newHostCursorStyle")).toBeUndefined();
|
||||
expect(admin("newHostCursorBlink")).toBe(false);
|
||||
expect(admin("newHostAutoTmux")).toBe(true);
|
||||
});
|
||||
|
||||
it("copies each host that switched something off, keeping the rest on", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The SSH connection options moving out of ssh_data.terminal_config into
|
||||
* the core ssh_options column, against a real SQLite database through the
|
||||
* same raw SQL helpers the boot path uses.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ db: null as unknown }));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runSshOptionsMigration } =
|
||||
await import("../../../utils/crypto-migration/ssh-options-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
terminal_config TEXT,
|
||||
ssh_options TEXT
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
function insert(id: number, terminalConfig: unknown, sshOptions?: string) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO ssh_data (id, terminal_config, ssh_options) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
terminalConfig === null ? null : JSON.stringify(terminalConfig),
|
||||
sshOptions ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function options(id: number): unknown {
|
||||
const row = sqlite
|
||||
.prepare("SELECT ssh_options FROM ssh_data WHERE id = ?")
|
||||
.get(id) as { ssh_options: string | null };
|
||||
return row.ssh_options === null ? null : JSON.parse(row.ssh_options);
|
||||
}
|
||||
|
||||
describe("runSshOptionsMigration", () => {
|
||||
it("copies only the connection options, leaving the look and old tunnel keys", async () => {
|
||||
insert(1, {
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent.sock",
|
||||
agentIdentity: "work",
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "LANG", value: "C" }],
|
||||
startupSnippetId: 4,
|
||||
cfAccessClientId: "old",
|
||||
cfTunnelHostname: "old.example.com",
|
||||
});
|
||||
|
||||
const result = await runSshOptionsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(1);
|
||||
expect(options(1)).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent.sock",
|
||||
agentIdentity: "work",
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "LANG", value: "C" }],
|
||||
});
|
||||
const terminalConfig = sqlite
|
||||
.prepare("SELECT terminal_config FROM ssh_data WHERE id = 1")
|
||||
.get() as { terminal_config: string };
|
||||
expect(JSON.parse(terminalConfig.terminal_config).keepaliveInterval).toBe(
|
||||
30,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a host with no options as done and skips hosts with no config", async () => {
|
||||
insert(1, { theme: "dracula" });
|
||||
insert(2, null);
|
||||
|
||||
const result = await runSshOptionsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(0);
|
||||
expect(options(1)).toEqual({});
|
||||
expect(options(2)).toBeNull();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after the options were edited", async () => {
|
||||
insert(1, { keepaliveInterval: 30 });
|
||||
insert(
|
||||
2,
|
||||
{ keepaliveInterval: 45 },
|
||||
JSON.stringify({ keepaliveCountMax: 9 }),
|
||||
);
|
||||
await runSshOptionsMigration();
|
||||
|
||||
sqlite
|
||||
.prepare("UPDATE ssh_data SET ssh_options = ? WHERE id = 1")
|
||||
.run(JSON.stringify({ keepaliveInterval: 90 }));
|
||||
const second = await runSshOptionsMigration();
|
||||
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(options(1)).toEqual({ keepaliveInterval: 90 });
|
||||
expect(options(2)).toEqual({ keepaliveCountMax: 9 });
|
||||
});
|
||||
|
||||
it("does not throw on a database without the column", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
state.db = drizzle(sqlite);
|
||||
await expect(runSshOptionsMigration()).resolves.toEqual({
|
||||
hostsMoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user