Files
Termix/src/backend/tests/database/routes/host-normalizers.test.ts
T

500 lines
15 KiB
TypeScript

import { afterEach, describe, it, expect } from "vitest";
import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js";
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
} from "../../../database/routes/host-normalizers.js";
describe("applyHostKeyTypeUpdate", () => {
it("clears an existing key type when auto-detect sends null", () => {
const update: Record<string, unknown> = { keyType: "ssh-ed25519" };
applyHostKeyTypeUpdate(update, null);
expect(update.keyType).toBeNull();
});
it("does not change the key type when the field is omitted", () => {
const update: Record<string, unknown> = { keyType: "ssh-ed25519" };
applyHostKeyTypeUpdate(update, undefined);
expect(update.keyType).toBe("ssh-ed25519");
});
});
describe("containsOwnerPrivateAuthUpdate", () => {
it("detects owner-only SSH auth fields, including explicit clears", () => {
expect(containsOwnerPrivateAuthUpdate({ password: null }, "ssh")).toBe(
true,
);
expect(
containsOwnerPrivateAuthUpdate({ credentialId: undefined }, "ssh"),
).toBe(true);
expect(
containsOwnerPrivateAuthUpdate({ authType: "password" }, "ssh"),
).toBe(true);
expect(containsOwnerPrivateAuthUpdate({ shareSshAuth: true }, "ssh")).toBe(
true,
);
});
it("leaves plugin protocol logins to their own guard", () => {
expect(
containsOwnerPrivateAuthUpdate(
{ protocolAuth: { spice: { credentialId: 7 } } },
"ssh",
),
).toBe(false);
});
it("allows shared editors to update non-authentication host settings", () => {
expect(
containsOwnerPrivateAuthUpdate(
{
name: "renamed",
ip: "10.0.0.5",
notes: "updated",
},
"ssh",
),
).toBe(false);
});
});
describe("isNonEmptyString", () => {
it("accepts non-blank strings", () => {
expect(isNonEmptyString("hello")).toBe(true);
expect(isNonEmptyString(" x ")).toBe(true);
});
it("rejects blank strings and non-strings", () => {
expect(isNonEmptyString("")).toBe(false);
expect(isNonEmptyString(" ")).toBe(false);
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
describe("isOptionalBoolean", () => {
it("accepts booleans and an omitted value", () => {
expect(isOptionalBoolean(true)).toBe(true);
expect(isOptionalBoolean(false)).toBe(true);
expect(isOptionalBoolean(undefined)).toBe(true);
});
it("rejects truthy string and numeric lookalikes", () => {
expect(isOptionalBoolean("false")).toBe(false);
expect(isOptionalBoolean("0")).toBe(false);
expect(isOptionalBoolean(1)).toBe(false);
expect(isOptionalBoolean(null)).toBe(false);
});
});
describe("normalizeProtocolEnableFields", () => {
it("omits unspecified protocol fields so database defaults are preserved", () => {
expect(normalizeProtocolEnableFields({ name: "server" })).toEqual({});
});
it("converts an explicitly provided SSH switch to a database integer", () => {
expect(
normalizeProtocolEnableFields({ enableSsh: true, enableRdp: false }),
).toEqual({ enableSsh: 1 });
});
});
describe("renameFolderPath", () => {
it("renames an exact folder match", () => {
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
});
it("re-paths nested children under the renamed ancestor", () => {
expect(renameFolderPath("Production / Web", "Production", "Prod")).toBe(
"Prod / Web",
);
expect(
renameFolderPath("Production / Web / app01", "Production", "Prod"),
).toBe("Prod / Web / app01");
});
it("renames a nested folder itself and keeps its parent", () => {
expect(
renameFolderPath("Production / Web", "Production / Web", "Frontend"),
).toBe("Frontend");
expect(
renameFolderPath(
"Production / Web / app01",
"Production / Web",
"Production / Frontend",
),
).toBe("Production / Frontend / app01");
});
it("returns null for unrelated folders", () => {
expect(renameFolderPath("Staging", "Production", "Prod")).toBeNull();
expect(renameFolderPath("Production2", "Production", "Prod")).toBeNull();
expect(
renameFolderPath("ProductionExtra / Web", "Production", "Prod"),
).toBeNull();
});
});
describe("isValidPort", () => {
it("accepts ports in range", () => {
expect(isValidPort(1)).toBe(true);
expect(isValidPort(22)).toBe(true);
expect(isValidPort(65535)).toBe(true);
});
it("rejects out-of-range or non-number ports", () => {
expect(isValidPort(0)).toBe(false);
expect(isValidPort(65536)).toBe(false);
expect(isValidPort(-1)).toBe(false);
expect(isValidPort("22")).toBe(false);
});
});
describe("normalizeImportedHost", () => {
it("defaults connectionType to ssh with port 22", () => {
const host = normalizeImportedHost({ ip: "10.0.0.1" });
expect(host.connectionType).toBe("ssh");
expect(host.port).toBe(22);
expect(host.enableSsh).toBe(true);
});
describe("with a plugin protocol declared", () => {
afterEach(() => setHostProtocolSource(() => []));
function declareSpice() {
setHostProtocolSource(() => [
{
id: "spice",
defaultPort: 5930,
pluginId: "spice-plugin",
pluginName: "Spice",
},
]);
}
it("infers the protocol from its enable flag and uses its default port", () => {
declareSpice();
const host = normalizeImportedHost({ enableSpice: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("spice");
expect(host.port).toBe(5930);
expect(host.enableSsh).toBe(false);
});
it("reads the protocol's own port field", () => {
declareSpice();
const host = normalizeImportedHost({
connectionType: "spice",
spicePort: 5999,
});
expect(host.port).toBe(5999);
});
it("ignores the flag of a protocol nobody declares", () => {
const host = normalizeImportedHost({ enableSpice: true });
expect(host.connectionType).toBe("ssh");
});
});
it("honors an explicit port over protocol defaults", () => {
const host = normalizeImportedHost({
connectionType: "ssh",
port: 2222,
});
expect(host.port).toBe(2222);
});
it("resolves ip from common aliases", () => {
expect(normalizeImportedHost({ address: "a.example" }).ip).toBe(
"a.example",
);
expect(normalizeImportedHost({ hostname: "h.example" }).ip).toBe(
"h.example",
);
});
it("normalizes tags from a comma string", () => {
const host = normalizeImportedHost({ tags: "prod, db , , web" });
expect(host.tags).toEqual(["prod", "db", "web"]);
});
it("normalizes tags from an array", () => {
const host = normalizeImportedHost({ tags: ["a", " b ", "", "c"] });
expect(host.tags).toEqual(["a", "b", "c"]);
});
it("infers authType credential when credentialId present", () => {
const host = normalizeImportedHost({ credentialId: 7 });
expect(host.credentialId).toBe(7);
expect(host.authType).toBe("credential");
});
it("infers credential auth from share aliases", () => {
const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
expect(aliasHost.credentialAlias).toBe("prod-admin");
expect(aliasHost.authType).toBe("credential");
const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
expect(nameHost.credentialAlias).toBe("ops-key");
expect(nameHost.authType).toBe("credential");
});
});
describe("stripSensitiveFields", () => {
it("removes secret fields and adds boolean presence flags", () => {
const result = stripSensitiveFields({
name: "web",
password: "secret",
key: "PRIVATE KEY",
keyPassword: "kp",
sudoPassword: "sp",
terminalConfig: {
theme: "termix",
sudoPassword: "nested-sudo",
},
});
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.keyPassword).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.terminalConfig).toEqual({ theme: "termix" });
expect(result.hasPassword).toBe(true);
expect(result.hasKey).toBe(true);
expect(result.hasKeyPassword).toBe(true);
expect(result.hasSudoPassword).toBe(true);
expect(result.name).toBe("web");
});
it("marks presence flags false when secrets are absent", () => {
const result = stripSensitiveFields({ name: "web" });
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
});
it("strips a sudo password nested in terminalConfig", () => {
const result = stripSensitiveFields({
name: "web",
terminalConfig: {
theme: "termix",
sudoPassword: "nested-only-sudo",
},
});
expect(result.hasSudoPassword).toBe(false);
expect(
(result.terminalConfig as Record<string, unknown>).sudoPassword,
).toBeUndefined();
});
it("leaves protocol login summaries alone for the owner", () => {
const protocolAuth = {
spice: { authType: "direct", hasPassword: true, secretFieldKeys: [] },
};
expect(stripSensitiveFields({ protocolAuth }).protocolAuth).toEqual(
protocolAuth,
);
});
});
describe("transformHostResponse", () => {
it("parses tags and coerces flags to booleans", () => {
const result = transformHostResponse({
tags: "a,b,c",
shareSshAuth: 1,
pin: 1,
});
expect(result.tags).toEqual(["a", "b", "c"]);
expect(result.shareSshAuth).toBe(true);
expect(result.pin).toBe(true);
});
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
jumpHosts: '[{"hostId":8}]',
portKnockSequence: null,
});
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
expect(result.portKnockSequence).toEqual([]);
});
it("leaves the 2.8 quick_actions column out, for its plugin to put back", () => {
const result = transformHostResponse({
quickActions: '[{"name":"x","snippetId":1}]',
});
expect(result.quickActions).toBeUndefined();
});
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 the default SSH port", () => {
const result = transformHostResponse({ port: 22 });
expect(result.sshPort).toBe(22);
expect(result).not.toHaveProperty("rdpPort");
});
});
describe("sanitizeHostForRecipient", () => {
const sharedHost = {
id: 42,
userId: "owner",
ownerUsername: "owner",
isShared: true,
permissionLevel: "view",
name: "prod",
ip: "10.0.0.42",
port: 22,
username: "root",
folder: "servers",
parentHostId: 17,
tags: ["linux"],
notes: "secret runbook",
quickActions: [{ name: "restart", snippetId: "1" }],
credentialId: 7,
shareSshAuth: true,
overrideCredentialUsername: true,
password: "hunter2",
key: "PRIVATE",
sudoPassword: "sudo",
socks5Password: "socks",
protocolAuth: {
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
},
enableSsh: true,
enableRdp: true,
sshPort: 22,
rdpPort: 3389,
defaultPath: "/srv",
terminalConfig: {
theme: "termix",
sudoPassword: "nested-sudo",
agentSocketPath: "/run/user/1000/ssh-agent.sock",
},
};
it("always strips secrets for recipients", () => {
const result = sanitizeHostForRecipient({ ...sharedHost }, "view");
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.socks5Password).toBeUndefined();
expect(result.protocolAuth).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
},
});
expect(result.credentialId).toBeUndefined();
expect(result.overrideCredentialUsername).toBeUndefined();
expect(result.terminalConfig).toEqual({ theme: "termix" });
expect(result.shareSshAuth).toBe(true);
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
// view keeps configuration fields
expect(result.notes).toBe("secret runbook");
expect(result.quickActions).toEqual(sharedHost.quickActions);
});
it("never exposes parentHostId to a recipient, at any permission level", () => {
// A recipient generally can't see (or share-permission on) the owner's
// parent host row, so sub-host tree structure is never leaked -- a
// shared host always renders at root for its recipient.
expect(
sanitizeHostForRecipient({ ...sharedHost }, "view").parentHostId,
).toBeUndefined();
expect(
sanitizeHostForRecipient({ ...sharedHost }, "manage").parentHostId,
).toBeUndefined();
expect(
sanitizeHostForRecipient({ ...sharedHost }, "connect").parentHostId,
).toBeUndefined();
});
it("reduces connect-level hosts to connection essentials", () => {
const result = sanitizeHostForRecipient(
{
...sharedHost,
permissionLevel: "connect",
authOverrides: {
ssh: {
credentialId: 9,
required: false,
ownerAuthShared: true,
},
},
},
"connect",
);
expect(result.name).toBe("prod");
expect(result.ip).toBe("10.0.0.42");
expect(result.protocolAuth).toEqual({ spice: { authType: "direct" } });
expect(result.permissionLevel).toBe("connect");
expect(result.shareSshAuth).toBe(true);
expect(result.authOverrides).toEqual({
ssh: {
credentialId: 9,
required: false,
ownerAuthShared: true,
},
});
expect(result.notes).toBeUndefined();
expect(result.quickActions).toBeUndefined();
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 });
});
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 });
});
});