feat: move terminal settings, look and ssh tools into ssh-terminal plugin

This commit is contained in:
LukeGus committed 2026-09-29 02:38:37 -05:00
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);
});
});
Binary file not shown.
+32 -1
View File
@@ -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"])],
+3
View File
@@ -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);
+38 -3
View File
@@ -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,
});
});
});