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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user