mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
buildCatalog,
|
||||
catalogNamespaces,
|
||||
isDefaultableField,
|
||||
} from "../../../hosts/defaults/catalog.js";
|
||||
import {
|
||||
defaultValuesEqual,
|
||||
normalizeAuthDefault,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
splitDefaultKey,
|
||||
stableStringify,
|
||||
} from "../../../../types/host-defaults.js";
|
||||
|
||||
const manifest = {
|
||||
id: "fixture",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "mode", type: "select", default: "a", options: [] },
|
||||
{ key: "token", type: "secret" },
|
||||
{ key: "config", type: "json", secretKeys: ["password"] },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
{
|
||||
key: "snippet",
|
||||
type: "number",
|
||||
defaultLevels: ["user", "folder"],
|
||||
},
|
||||
{ key: "font", type: "number", default: 14, personal: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
describe("the catalog", () => {
|
||||
it("holds core's keys and each defaultable plugin field", () => {
|
||||
const catalog = buildCatalog([manifest]);
|
||||
expect(catalog.has("core.sshPort")).toBe(true);
|
||||
expect(catalog.has("core.auth")).toBe(true);
|
||||
expect(catalog.has("fixture.enabled")).toBe(true);
|
||||
expect(catalog.has("fixture.mode")).toBe(true);
|
||||
expect(catalog.has("fixture.token")).toBe(false);
|
||||
expect(catalog.has("fixture.config")).toBe(false);
|
||||
expect(catalog.has("fixture.mac")).toBe(false);
|
||||
expect(catalog.get("fixture.snippet")?.levels).toEqual(["user", "folder"]);
|
||||
expect(catalog.get("fixture.font")?.personal).toBe(true);
|
||||
expect(catalog.get("fixture.enabled")?.builtin).toBe(true);
|
||||
expect(catalogNamespaces(catalog)).toEqual(["core", "fixture"]);
|
||||
});
|
||||
|
||||
it("reads an unset plugin value as its manifest default", () => {
|
||||
const mode = buildCatalog([manifest]).get("fixture.mode")!;
|
||||
expect(mode.normalize(null)).toBe("a");
|
||||
expect(mode.normalize("")).toBe("a");
|
||||
expect(mode.normalize("b")).toBe("b");
|
||||
});
|
||||
|
||||
it("never lets a secret be a default", () => {
|
||||
expect(isDefaultableField({ key: "s", type: "secret" })).toBe(false);
|
||||
expect(
|
||||
isDefaultableField({ key: "s", type: "secret", defaultable: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value shapes", () => {
|
||||
it("keeps only the auth fields its type uses", () => {
|
||||
expect(
|
||||
normalizeAuthDefault({
|
||||
authType: "agent",
|
||||
credentialId: 4,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
}),
|
||||
).toEqual({
|
||||
authType: "agent",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
agentIdentity: null,
|
||||
});
|
||||
expect(normalizeAuthDefault(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a proxy's settings while it is off, and chain passwords always", () => {
|
||||
expect(
|
||||
normalizeSocks5Default({ useSocks5: false, socks5Host: "x" }).socks5Host,
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeSocks5Default({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a", password: "p" }],
|
||||
}).socks5ProxyChain,
|
||||
).toEqual([{ host: "a" }]);
|
||||
});
|
||||
|
||||
it("compares objects regardless of key order", () => {
|
||||
expect(stableStringify({ b: 1, a: 2 })).toBe(
|
||||
stableStringify({ a: 2, b: 1 }),
|
||||
);
|
||||
expect(defaultValuesEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses overrides and splits keys", () => {
|
||||
expect(parseDefaultOverrides('{"core":["b","a","a"],"x":"no"}')).toEqual({
|
||||
core: ["a", "b"],
|
||||
});
|
||||
expect(parseDefaultOverrides(null)).toBeNull();
|
||||
expect(splitDefaultKey("ssh-terminal.fontSize")).toEqual([
|
||||
"ssh-terminal",
|
||||
"fontSize",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readCoreDefault,
|
||||
writeCoreDefault,
|
||||
} from "../../../hosts/defaults/core-columns.js";
|
||||
|
||||
describe("core defaults in column form", () => {
|
||||
it("reads the SSH options a row keeps as JSON", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({ keepaliveInterval: 30 }),
|
||||
};
|
||||
expect(readCoreDefault("keepaliveInterval", row)).toBe(30);
|
||||
expect(readCoreDefault("keepaliveCountMax", row)).toBe(5);
|
||||
expect(readCoreDefault("allowLegacyAlgorithms", row)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one SSH option and keeps the others", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({
|
||||
keepaliveInterval: 30,
|
||||
agentForwarding: true,
|
||||
}),
|
||||
};
|
||||
const patch = writeCoreDefault("keepaliveInterval", 90, row);
|
||||
expect(JSON.parse(patch.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 90,
|
||||
agentForwarding: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves an SSH host's port with its SSH port, but not a plugin protocol's", () => {
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "ssh" }),
|
||||
).toEqual({ sshPort: 2222, port: 2222 });
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "rdp" }),
|
||||
).toEqual({ sshPort: 2222 });
|
||||
});
|
||||
|
||||
it("writes a credential login and clears any key the host held", () => {
|
||||
const patch = writeCoreDefault(
|
||||
"auth",
|
||||
{ authType: "credential", credentialId: 7 },
|
||||
{ sshOptions: null, password: "kept" },
|
||||
);
|
||||
expect(patch).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
});
|
||||
expect(patch).not.toHaveProperty("password");
|
||||
expect(
|
||||
writeCoreDefault("auth", { authType: "agent" }, {}).password,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a proxy and keeps the host's own chain passwords", () => {
|
||||
const row = {
|
||||
socks5ProxyChain: JSON.stringify([{ host: "a", password: "mine" }]),
|
||||
};
|
||||
const patch = writeCoreDefault(
|
||||
"socks5",
|
||||
{ useSocks5: true, socks5ProxyChain: [{ host: "a" }] },
|
||||
row,
|
||||
);
|
||||
expect(JSON.parse(patch.socks5ProxyChain as string)).toEqual([
|
||||
{ host: "a", password: "mine" },
|
||||
]);
|
||||
expect(readCoreDefault("socks5", { ...row, ...patch })).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores empty lists as null and booleans as the column expects", () => {
|
||||
expect(writeCoreDefault("jumpHosts", [], {})).toEqual({ jumpHosts: null });
|
||||
expect(writeCoreDefault("forceKeyboardInteractive", true, {})).toEqual({
|
||||
forceKeyboardInteractive: "true",
|
||||
});
|
||||
expect(
|
||||
readCoreDefault("forceKeyboardInteractive", {
|
||||
forceKeyboardInteractive: "false",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The materialize pass against in-memory tables: hosts, plugin host rows and
|
||||
* the defaults levels.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown> & { id: number; userId: string };
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
hosts: [] as Array<Record<string, unknown> & { id: number; userId: string }>,
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}>,
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; name: string }>,
|
||||
events: [] as unknown[],
|
||||
notified: [] as unknown[],
|
||||
}));
|
||||
|
||||
const manifest = vi.hoisted(() => ({
|
||||
id: "term",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [manifest],
|
||||
}));
|
||||
vi.mock("../../../plugins/events.js", () => ({
|
||||
TOPICS: { hostUpdated: "host.updated" },
|
||||
pluginEvents: { emit: (...args: unknown[]) => db.events.push(args) },
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({ markChanged: () => {} }));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ resyncHost: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/settings.js")>()),
|
||||
notifySettingChange: (...args: unknown[]) => db.notified.push(args),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async (filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}) =>
|
||||
db.hosts.filter(
|
||||
(host) =>
|
||||
filter.all ||
|
||||
filter.hostIds?.includes(host.id) ||
|
||||
filter.userIds?.includes(host.userId),
|
||||
),
|
||||
listForUsers: async (userIds: string[]) =>
|
||||
db.defaults.filter(
|
||||
(row) =>
|
||||
row.level === "admin" || userIds.includes(row.userId as string),
|
||||
),
|
||||
listFolders: async (userIds: string[]) =>
|
||||
db.folders.filter((folder) => userIds.includes(folder.userId)),
|
||||
listHostPlacement: async (userIds: string[]) =>
|
||||
db.hosts
|
||||
.filter((host) => userIds.includes(host.userId))
|
||||
.map((host) => ({
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
parentHostId: (host.parentHostId as number | null) ?? null,
|
||||
})),
|
||||
updateHosts: async (patches: Array<{ id: number; values: object }>) => {
|
||||
for (const patch of patches) {
|
||||
const host = db.hosts.find((row) => row.id === patch.id)!;
|
||||
Object.assign(host, patch.values);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
getAllForScopeIds: async (_scope: string, ids: string[]) =>
|
||||
db.pluginRows.filter((row) => ids.includes(row.scopeId)),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
const existing = db.pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) existing.value = value;
|
||||
else
|
||||
db.pluginRows.push({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted: false,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
_scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { materializeHosts } =
|
||||
await import("../../../hosts/defaults/materialize.js");
|
||||
|
||||
function host(partial: Partial<Row> & { id: number }): Row {
|
||||
return {
|
||||
userId: "u1",
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
sshPort: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
folder: null,
|
||||
parentHostId: null,
|
||||
statusCheckEnabled: true,
|
||||
defaultOverrides: null,
|
||||
sharedSource: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setDefault(
|
||||
level: "admin" | "user" | "folder",
|
||||
key: string,
|
||||
value: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const [namespace, name] = key.split(".");
|
||||
db.defaults.push({
|
||||
level,
|
||||
namespace,
|
||||
key: name,
|
||||
value: JSON.stringify(value),
|
||||
userId: level === "admin" ? null : "u1",
|
||||
folderId: null,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function pluginValue(hostId: number, key: string): unknown {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row ? JSON.parse(row.value as string) : undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.hosts = [];
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.folders = [];
|
||||
db.events = [];
|
||||
db.notified = [];
|
||||
});
|
||||
|
||||
describe("materializeHosts", () => {
|
||||
it("classifies a host from before host defaults: matching values follow them", async () => {
|
||||
db.hosts = [host({ id: 1, sshPort: 22 }), host({ id: 2, sshPort: 2222 })];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "2",
|
||||
key: "fontSize",
|
||||
value: "18",
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const result = await materializeHosts({ all: true });
|
||||
|
||||
expect(result.changedHostIds).toEqual([]);
|
||||
const first = JSON.parse(db.hosts[0].defaultOverrides as string);
|
||||
const second = JSON.parse(db.hosts[1].defaultOverrides as string);
|
||||
expect(first.core).not.toContain("sshPort");
|
||||
expect(second.core).toContain("sshPort");
|
||||
expect(first.term).toEqual([]);
|
||||
expect(second.term).toEqual(["fontSize"]);
|
||||
// Nothing a user sees changed.
|
||||
expect(db.hosts[1].sshPort).toBe(2222);
|
||||
});
|
||||
|
||||
it("writes a new default into every host that follows it, and no other", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
host({
|
||||
id: 2,
|
||||
sshPort: 2200,
|
||||
defaultOverrides: JSON.stringify({ core: ["sshPort"], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("user", "core.sshPort", 2022);
|
||||
setDefault("admin", "term.fontSize", 16);
|
||||
|
||||
const result = await materializeHosts({ userIds: ["u1"] });
|
||||
|
||||
expect(result.changedHostIds).toEqual([1, 2]);
|
||||
expect(db.hosts[0].sshPort).toBe(2022);
|
||||
expect(db.hosts[0].port).toBe(2022);
|
||||
expect(db.hosts[1].sshPort).toBe(2200);
|
||||
expect(pluginValue(1, "fontSize")).toBe(16);
|
||||
expect(pluginValue(2, "fontSize")).toBe(16);
|
||||
expect(db.events).toHaveLength(2);
|
||||
expect(db.notified).toEqual([["term", "fontSize", 16]]);
|
||||
});
|
||||
|
||||
it("removes the row when a key goes back to the manifest default", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "fontSize",
|
||||
value: "16",
|
||||
encrypted: false,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(pluginValue(1, "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the deepest folder, and a sub-host follows its parent's", async () => {
|
||||
db.folders = [
|
||||
{ id: 10, userId: "u1", name: "Prod" },
|
||||
{ id: 11, userId: "u1", name: "Prod / Web" },
|
||||
];
|
||||
const classified = JSON.stringify({ core: [], term: [] });
|
||||
db.hosts = [
|
||||
host({ id: 1, folder: "Prod / Web", defaultOverrides: classified }),
|
||||
host({ id: 2, parentHostId: 1, defaultOverrides: classified }),
|
||||
host({ id: 3, folder: "Prod", defaultOverrides: classified }),
|
||||
];
|
||||
setDefault("folder", "core.sshPort", 2100, { folderId: 10 });
|
||||
setDefault("folder", "core.sshPort", 2200, { folderId: 11 });
|
||||
|
||||
await materializeHosts({ all: true });
|
||||
|
||||
expect(db.hosts.map((row) => row.sshPort)).toEqual([2200, 2200, 2100]);
|
||||
});
|
||||
|
||||
it("never writes a jump chain through the host itself", async () => {
|
||||
db.folders = [{ id: 10, userId: "u1", name: "Lab" }];
|
||||
db.hosts = [
|
||||
host({
|
||||
id: 1,
|
||||
folder: "Lab",
|
||||
defaultOverrides: JSON.stringify({ core: [], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("folder", "core.jumpHosts", [{ hostId: 1 }, { hostId: 9 }], {
|
||||
folderId: 10,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(JSON.parse(db.hosts[0].jumpHosts as string)).toEqual([
|
||||
{ hostId: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("previews without writing", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
const result = await materializeHosts(
|
||||
{ all: true },
|
||||
{
|
||||
dryRun: true,
|
||||
overlay: {
|
||||
level: "admin",
|
||||
set: new Map([["core.sshPort", 2022]]),
|
||||
unset: new Set(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.changedHostIds).toEqual([1]);
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
});
|
||||
|
||||
it("leaves a desktop's copy of a shared host alone", async () => {
|
||||
db.hosts = [host({ id: 1, sharedSource: "{}" })];
|
||||
setDefault("admin", "core.sshPort", 2022);
|
||||
await materializeHosts({ all: true });
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
expect(db.hosts[0].defaultOverrides).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import {
|
||||
effectiveFolderPath,
|
||||
folderChainPaths,
|
||||
isOwnValue,
|
||||
resolveKey,
|
||||
withoutSelfJump,
|
||||
type HostLevels,
|
||||
} from "../../../hosts/defaults/resolve.js";
|
||||
import { coreCatalogEntries } from "../../../hosts/defaults/catalog.js";
|
||||
|
||||
const entry = (key: string) =>
|
||||
coreCatalogEntries().find((info) => info.key === key)!;
|
||||
|
||||
function levels(partial: Partial<HostLevels> = {}): HostLevels {
|
||||
return {
|
||||
admin: new Map(),
|
||||
user: new Map(),
|
||||
folders: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("folderChainPaths", () => {
|
||||
it("lists every ancestor, root first", () => {
|
||||
expect(folderChainPaths("A / B / C")).toEqual(["A", "A / B", "A / B / C"]);
|
||||
expect(folderChainPaths(null)).toEqual([]);
|
||||
expect(folderChainPaths("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveFolderPath", () => {
|
||||
it("takes a sub-host's folder from its nearest placed ancestor", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: "Prod", parentHostId: null }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
[3, { folder: null, parentHostId: 2 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(3, placement)).toBe("Prod");
|
||||
expect(effectiveFolderPath(1, placement)).toBe("Prod");
|
||||
});
|
||||
|
||||
it("stops on a parent cycle", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: null, parentHostId: 2 }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(1, placement)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKey", () => {
|
||||
it("prefers the deepest folder, then the user, then the server, then built-in", () => {
|
||||
const port = entry("sshPort");
|
||||
expect(resolveKey(port, levels())).toEqual({
|
||||
value: 22,
|
||||
source: { level: "builtin" },
|
||||
});
|
||||
expect(
|
||||
resolveKey(port, levels({ admin: new Map([["core.sshPort", 2200]]) }))
|
||||
?.source.level,
|
||||
).toBe("admin");
|
||||
const all = levels({
|
||||
admin: new Map([["core.sshPort", 2200]]),
|
||||
user: new Map([["core.sshPort", 2201]]),
|
||||
folders: [
|
||||
{ folderId: 1, name: "A", values: new Map([["core.sshPort", 2202]]) },
|
||||
{
|
||||
folderId: 2,
|
||||
name: "A / B",
|
||||
values: new Map([["core.sshPort", 2203]]),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(resolveKey(port, all)).toEqual({
|
||||
value: 2203,
|
||||
source: { level: "folder", folderId: 2, folderName: "A / B" },
|
||||
});
|
||||
all.folders[1].values.clear();
|
||||
expect(resolveKey(port, all)?.value).toBe(2202);
|
||||
});
|
||||
|
||||
it("skips a level the key cannot be set at", () => {
|
||||
const jumps = entry("jumpHosts");
|
||||
const result = resolveKey(
|
||||
jumps,
|
||||
levels({ admin: new Map([["core.jumpHosts", [{ hostId: 5 }]]]) }),
|
||||
);
|
||||
expect(result?.source.level).toBe("builtin");
|
||||
expect(result?.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves nothing for a key with no built-in value and no level", () => {
|
||||
expect(resolveKey(entry("username"), levels())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOwnValue", () => {
|
||||
it("compares normalized values", () => {
|
||||
const port = entry("sshPort");
|
||||
const resolved = { value: 22, source: { level: "builtin" as const } };
|
||||
expect(isOwnValue(port, "22", resolved)).toBe(false);
|
||||
expect(isOwnValue(port, null, resolved)).toBe(false);
|
||||
expect(isOwnValue(port, 2222, resolved)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats any value as the host's own when nothing resolves", () => {
|
||||
const username = entry("username");
|
||||
expect(isOwnValue(username, "root", undefined)).toBe(true);
|
||||
expect(isOwnValue(username, "", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats keepalive left unset as the built-in value", () => {
|
||||
const keepalive = entry("keepaliveInterval");
|
||||
expect(
|
||||
isOwnValue(keepalive, undefined, {
|
||||
value: 60,
|
||||
source: { level: "builtin" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutSelfJump", () => {
|
||||
it("drops the host itself from an inherited jump chain", () => {
|
||||
expect(withoutSelfJump([{ hostId: 1 }, { hostId: 2 }], 2)).toEqual([
|
||||
{ hostId: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
|
||||
const { validateLevelChange } =
|
||||
await import("../../../hosts/defaults/service.js");
|
||||
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
|
||||
|
||||
const catalog = buildCatalog([
|
||||
{
|
||||
id: "fx",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "size", type: "number", min: 8, max: 36, default: 14 },
|
||||
{ key: "snippet", type: "number", defaultLevels: ["user"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest,
|
||||
]);
|
||||
|
||||
const context = {
|
||||
actorId: "u1",
|
||||
ownerId: "u1",
|
||||
canUseCredential: async (id: number) => id === 7,
|
||||
canUseHost: async (id: number) => id === 3,
|
||||
};
|
||||
|
||||
describe("validateLevelChange", () => {
|
||||
it("accepts known keys and normalizes their values", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.sshPort": "2022", "fx.size": "16" }, unset: ["fx.x"] },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors).toEqual({});
|
||||
expect(change.set.get("core.sshPort")).toBe(2022);
|
||||
expect(change.set.get("fx.size")).toBe(16);
|
||||
expect([...change.unset]).toEqual(["fx.x"]);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, out of range values and levels a key cannot use", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{
|
||||
set: {
|
||||
"core.name": "x",
|
||||
"fx.size": 99,
|
||||
"fx.snippet": 1,
|
||||
"core.jumpHosts": [{ hostId: 3 }],
|
||||
},
|
||||
},
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(Object.keys(change.errors).sort()).toEqual([
|
||||
"core.jumpHosts",
|
||||
"core.name",
|
||||
"fx.size",
|
||||
"fx.snippet",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never takes a password or key login, or a credential at the server level", async () => {
|
||||
const password = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "password" } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(password.errors["core.auth"]).toMatch(/cannot be a default/);
|
||||
|
||||
const serverCredential = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(serverCredential.errors["core.auth"]).toMatch(/server default/);
|
||||
|
||||
const ownCredential = await validateLevelChange(
|
||||
{ level: "folder", folderId: 1, userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(ownCredential.errors).toEqual({});
|
||||
|
||||
const otherCredential = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 8 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(otherCredential.errors["core.auth"]).toMatch(/not available/);
|
||||
});
|
||||
|
||||
it("checks every jump host in a chain", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.jumpHosts": [{ hostId: 3 }, { hostId: 4 }] } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors["core.jumpHosts"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listForUsers: async () => db.defaults,
|
||||
listFolders: async () => [],
|
||||
listHostPlacement: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { applyHostDefaultsToWrite } =
|
||||
await import("../../../hosts/defaults/write-rules.js");
|
||||
const { applyOverrideChange } =
|
||||
await import("../../../hosts/defaults/overrides.js");
|
||||
|
||||
function userDefault(key: string, value: unknown) {
|
||||
db.defaults.push({
|
||||
level: "user",
|
||||
userId: "u1",
|
||||
folderId: null,
|
||||
namespace: "core",
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.defaults = [];
|
||||
});
|
||||
|
||||
describe("applyHostDefaultsToWrite", () => {
|
||||
it("takes the editor's list as the answer and fills what it inherits", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 9999,
|
||||
port: 9999,
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { defaultOverrides: { core: [], term: ["fontSize"] } },
|
||||
});
|
||||
expect(columns.sshPort).toBe(2022);
|
||||
expect(columns.port).toBe(2022);
|
||||
expect(overrides.core).toEqual([]);
|
||||
expect(overrides.term).toEqual(["fontSize"]);
|
||||
});
|
||||
|
||||
it("inherits what a create leaves out, and keeps what it sets", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
userDefault("statusCheckEnabled", false);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 22,
|
||||
port: 22,
|
||||
statusCheckEnabled: 1,
|
||||
username: "deploy",
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { ip: "10.0.0.1", port: 22, username: "deploy" },
|
||||
});
|
||||
expect(columns.statusCheckEnabled).toBe(false);
|
||||
// The port was sent and differs from the default, so it is the host's.
|
||||
expect(overrides.core).toContain("sshPort");
|
||||
expect(columns.sshPort).toBe(22);
|
||||
expect(overrides.core).toContain("username");
|
||||
});
|
||||
|
||||
it("marks a changed key on an update without a list, and keeps the rest", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const stored = {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: true,
|
||||
username: "root",
|
||||
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||
};
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: 0,
|
||||
username: "root",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns,
|
||||
body: {},
|
||||
stored: stored as never,
|
||||
});
|
||||
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||
});
|
||||
|
||||
it("keeps a locked key's state for a shared editor", async () => {
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns: {},
|
||||
body: { defaultOverrides: { core: [] } },
|
||||
stored: {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
defaultOverrides: JSON.stringify({ core: ["auth"] }),
|
||||
} as never,
|
||||
lockedKeys: ["auth"],
|
||||
});
|
||||
expect(overrides.core).toContain("auth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOverrideChange", () => {
|
||||
it("adds and removes keys, per namespace", () => {
|
||||
const next = applyOverrideChange(
|
||||
{ core: ["sshPort"], term: ["fontSize", "theme"] },
|
||||
{ own: [["core", "username"]], inherit: [["term", "theme"]] },
|
||||
);
|
||||
expect(next).toEqual({ core: ["sshPort", "username"], term: ["fontSize"] });
|
||||
});
|
||||
|
||||
it("leaves a namespace nobody classified yet to its first pass", () => {
|
||||
expect(applyOverrideChange({ core: [] }, { own: [["term", "x"]] })).toEqual(
|
||||
{ core: [] },
|
||||
);
|
||||
expect(applyOverrideChange(null, { own: [["core", "x"]] })).toEqual({});
|
||||
});
|
||||
|
||||
it("resets everything, known namespaces included", () => {
|
||||
expect(
|
||||
applyOverrideChange({ core: ["a"] }, { inheritAll: true }, [
|
||||
"core",
|
||||
"term",
|
||||
]),
|
||||
).toEqual({ core: [], term: [] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user