feat: redesign user host defaults system

This commit is contained in:
LukeGus committed 2026-09-30 06:19:56 -05:00
1 parent 3028ab1176
commit a4a4d4f27b
144 files changed
+24609 -2555

No files matched your search

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostDefaultsRepository } from "../../../database/repositories/host-defaults-repository.js";
describe("HostDefaultsRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(): Promise<HostDefaultsRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_folders (id, user_id, name)
VALUES (1, 'user-1', 'prod'), (2, 'user-2', 'prod');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
VALUES
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
(2, 'user-2', 'two', '10.0.0.2', 22, 'root', null, 'password');
`);
return new HostDefaultsRepository(context);
}
it("keeps each level apart, the server level included", async () => {
const repository = await createRepository();
await repository.apply(
{ level: "admin" },
[{ namespace: "core", key: "sshPort", value: "2022" }],
[],
"user-1",
);
await repository.apply(
{ level: "user", userId: "user-1" },
[{ namespace: "core", key: "sshPort", value: "2023" }],
[],
"user-1",
);
await repository.apply(
{ level: "folder", userId: "user-1", folderId: 1 },
[{ namespace: "term", key: "fontSize", value: "16" }],
[],
"user-1",
);
expect(await repository.listScope({ level: "admin" })).toHaveLength(1);
const forUser = await repository.listForUsers(["user-1"]);
expect(forUser.map((row) => `${row.level}:${row.value}`).sort()).toEqual([
"admin:2022",
"folder:16",
"user:2023",
]);
expect(await repository.listForUsers(["user-2"])).toHaveLength(1);
});
it("updates a key in place and clears one", async () => {
const repository = await createRepository();
const scope = { level: "user" as const, userId: "user-1" };
await repository.apply(
scope,
[{ namespace: "core", key: "sshPort", value: "1" }],
[],
null,
);
await repository.apply(
scope,
[{ namespace: "core", key: "sshPort", value: "2" }],
[],
null,
);
expect((await repository.listScope(scope)).map((row) => row.value)).toEqual(
["2"],
);
await repository.apply(
scope,
[],
[{ namespace: "core", key: "sshPort" }],
null,
);
expect(await repository.listScope(scope)).toEqual([]);
});
it("finds or creates a folder row for a path", async () => {
const repository = await createRepository();
expect(await repository.ensureFolder("user-1", "prod")).toBe(1);
const created = await repository.ensureFolder("user-1", "prod / web");
expect(await repository.findFolder(created)).toMatchObject({
userId: "user-1",
name: "prod / web",
});
});
it("reads and writes hosts in bulk", async () => {
const repository = await createRepository();
expect(
(await repository.listHosts({ userIds: ["user-1"] })).map(
(row) => row.id,
),
).toEqual([1]);
await repository.updateHosts([
{ id: 1, values: { sshPort: 2022, defaultOverrides: '{"core":[]}' } },
]);
const [row] = await repository.listHosts({ hostIds: [1] });
expect(row.sshPort).toBe(2022);
expect(row.defaultOverrides).toBe('{"core":[]}');
expect(await repository.listHostPlacement(["user-1"])).toEqual([
{ id: 1, userId: "user-1", folder: "prod", parentHostId: null },
]);
});
});
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import request from "supertest";
const state = vi.hoisted(() => ({
isAdmin: true,
access: { hasAccess: true, isOwner: true },
saved: [] as unknown[],
resets: [] as unknown[],
audits: [] as unknown[],
folders: new Map<number, { id: number; userId: string; name: string }>(),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getAuditUsername: async () => "admin",
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => state.access,
hasPermission: async () => state.isAdmin,
}),
},
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async () => ({}),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
findFolder: async (id: number) => state.folders.get(id) ?? null,
ensureFolder: async () => 42,
listHosts: async () => [{ id: 1, userId: "u1" }],
}),
}));
vi.mock("../../../hosts/defaults/materialize.js", () => ({
currentCatalog: () => ({ catalog: new Map() }),
materializeHosts: async () => ({ changedHostIds: [1, 2] }),
}));
vi.mock("../../../hosts/defaults/recompute.js", () => ({
getRecomputeJob: (id: string) =>
id === "job-1" ? { id, status: "done", changedHosts: 3 } : undefined,
recompute: async () => ({ changedHostIds: [5] }),
startRecomputeJob: () => ({ id: "job-1" }),
targetForScope: () => ({ all: true }),
}));
vi.mock("../../../hosts/defaults/service.js", () => ({
levelsAbove: async () => ({ admin: new Map(), user: new Map(), folders: [] }),
readLevel: async () => ({ "core.sshPort": 2022 }),
resolveAll: () => ({}),
resolveForEditor: async () => ({
"core.sshPort": { value: 2022, source: { level: "user" } },
}),
saveLevel: async (scope: unknown, change: unknown) => {
state.saved.push({ scope, change });
},
validateLevelChange: async (
_scope: unknown,
input: { set?: Record<string, unknown> },
) =>
input.set && "bad.key" in input.set
? { set: new Map(), unset: new Set(), errors: { "bad.key": "no" } }
: {
set: new Map(Object.entries(input.set ?? {})),
unset: new Set(),
errors: {},
},
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async (hostIds: number[], change: unknown) => {
state.resets.push({ hostIds, change });
},
}));
vi.mock("../../../database/routes/host-bulk-routes.js", async () => {
const actual = await vi.importActual<
typeof import("../../../database/routes/host-bulk-routes.js")
>("../../../database/routes/host-bulk-routes.js");
return { readDefaultsReset: actual.readDefaultsReset };
});
const { registerHostDefaultsRoutes } =
await import("../../../database/routes/host-defaults-routes.js");
const pass: RequestHandler = (_req, _res, next) => next();
const adminOnly: RequestHandler = (_req, res, next) =>
state.isAdmin ? next() : void res.status(403).json({ error: "no" });
function app() {
const router = express.Router();
router.use((req, _res, next) => {
(req as unknown as { userId: string }).userId = "u1";
next();
});
registerHostDefaultsRoutes(router, {
authenticateJWT: pass,
requireEditPermission: pass,
requireDataAccess: pass,
requireAdminSettings: adminOnly,
});
const server = express();
server.use(express.json());
server.use("/host", router);
return server;
}
beforeEach(() => {
state.isAdmin = true;
state.access = { hasAccess: true, isOwner: true };
state.saved = [];
state.resets = [];
state.audits = [];
state.folders = new Map([
[1, { id: 1, userId: "u1", name: "Prod" }],
[2, { id: 2, userId: "u2", name: "Theirs" }],
]);
});
describe("host defaults routes", () => {
it("keeps the server level to admins", async () => {
state.isAdmin = false;
expect((await request(app()).get("/host/defaults/admin")).status).toBe(403);
expect(
(await request(app()).put("/host/defaults/admin").send({ set: {} }))
.status,
).toBe(403);
});
it("saves the server level as a background job, audited", async () => {
const response = await request(app())
.put("/host/defaults/admin")
.send({ set: { "core.sshPort": 2022 } });
expect(response.body).toEqual({ jobId: "job-1" });
expect(state.audits).toHaveLength(1);
const job = await request(app()).get("/host/defaults/jobs/job-1");
expect(job.body.changedHosts).toBe(3);
});
it("saves a user's own level and says how many hosts changed", async () => {
const response = await request(app())
.put("/host/defaults/user")
.send({ set: { "core.sshPort": 2022 } });
expect(response.body).toEqual({ changedHosts: 1 });
expect(state.saved[0]).toMatchObject({
scope: { level: "user", userId: "u1" },
});
});
it("reports rejected values", async () => {
const response = await request(app())
.put("/host/defaults/user")
.send({ set: { "bad.key": 1 } });
expect(response.status).toBe(400);
expect(response.body.errors).toEqual({ "bad.key": "no" });
});
it("only opens a folder the user owns", async () => {
expect((await request(app()).get("/host/defaults/folders/1")).status).toBe(
200,
);
expect((await request(app()).get("/host/defaults/folders/2")).status).toBe(
404,
);
const created = await request(app())
.post("/host/defaults/folders")
.send({ name: "New" });
expect(created.body).toEqual({ id: 42 });
});
it("previews a change without saving it", async () => {
const response = await request(app())
.post("/host/defaults/preview")
.send({ level: "user", set: { "core.sshPort": 1 } });
expect(response.body).toEqual({ changedHosts: 2 });
expect(state.saved).toEqual([]);
});
it("resolves a host's defaults for the editor", async () => {
const response = await request(app()).get(
"/host/defaults/resolve?hostId=1",
);
expect(response.body.values["core.sshPort"].value).toBe(2022);
});
it("resets a host, but never a shared editor over the owner's login", async () => {
await request(app())
.post("/host/db/host/7/reset-defaults")
.send({ keys: ["core.sshPort"] });
expect(state.resets).toHaveLength(1);
state.access = { hasAccess: true, isOwner: false };
const all = await request(app())
.post("/host/db/host/7/reset-defaults")
.send({ all: true });
expect(all.status).toBe(403);
expect(
(await request(app()).post("/host/db/host/7/reset-defaults").send({}))
.status,
).toBe(400);
});
});
@@ -11,7 +11,17 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listHosts: async () => [],
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => [],
}),
@@ -52,6 +52,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
}));
@@ -1,7 +1,7 @@
/**
* 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.
* The old admin host defaults routes are gone: host defaults live under
* /host/defaults now. The GET used to hand the admin's proxy password to any
* signed-in user.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -65,40 +65,18 @@ beforeEach(() => {
});
describe("/users/host-defaults", () => {
it("serves core's defaults without the terminal keys the plugin took over", async () => {
it("is no longer served, so the stored row never leaves the server", async () => {
state.settings.host_defaults = JSON.stringify({
useSocks5: true,
fontSize: 18,
autoTmux: true,
socks5Password: "secret",
});
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())
const read = await request(app()).get("/users/host-defaults");
expect(read.status).toBe(404);
expect(JSON.stringify(read.body)).not.toContain("secret");
const write = 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);
.send({ useSocks5: false });
expect(write.status).toBe(404);
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
});
});
+22
View File
@@ -450,6 +450,28 @@ CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`crede
-- install loses what it stored.
SELECT 1;
-- ==== core 0065_ambiguous_agent_zero ====
CREATE TABLE `host_defaults` (
`id` int AUTO_INCREMENT NOT NULL,
`level` enum('admin','user','folder') NOT NULL,
`scope_key` varchar(255) NOT NULL,
`user_id` varchar(255),
`folder_id` int,
`namespace` varchar(255) NOT NULL,
`key` varchar(255) NOT NULL,
`value` text,
`updated_by` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
+21
View File
@@ -469,6 +469,27 @@ CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING b
-- install loses what it stored.
SELECT 1;
-- ==== core 0065_regular_joystick ====
CREATE TABLE "host_defaults" (
"id" serial PRIMARY KEY NOT NULL,
"level" text NOT NULL,
"scope_key" varchar(255) NOT NULL,
"user_id" varchar(255),
"folder_id" integer,
"namespace" varchar(255) NOT NULL,
"key" varchar(255) NOT NULL,
"value" text,
"updated_by" text,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -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: [] });
});
});
@@ -65,6 +65,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listHosts: async () => [],
}),
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: async () => h.ownedHosts,
listHostRowsForAccessList: async () => h.sharedRows,
@@ -185,6 +188,14 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
},
}));
vi.mock("../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
}));
vi.mock("../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId });
@@ -77,7 +77,14 @@ vi.mock("../../../utils/permission-manager.js", () => ({
}),
},
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listHosts: async () => [],
}),
createCurrentHostRepository: () => ({
createEncryptedForUser: async () => secretHost(1),
updateEncryptedForUser: async () => secretHost(1),
@@ -58,6 +58,10 @@ const UNGATED: Record<string, string> = {
"settings.getUser": "the plugin's own user settings",
"settings.setUser": "the plugin's own user settings",
"settings.getHost": "the plugin's own host settings",
"settings.getHostFor":
"the plugin's own host settings, as one user sees them",
"settings.getHostDefault":
"the plugin's own host settings, as a user's defaults",
"settings.setHost": "the plugin's own host settings",
"settings.getAll": "the plugin's own settings",
"settings.onChange": "the plugin's own settings",
@@ -418,14 +418,62 @@ describe("host field options", () => {
).toEqual([]);
});
it("rejects a defaultFrom that names a secret or unknown admin field", () => {
for (const defaultFrom of ["token", "missing"]) {
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom }]),
).join(),
).toMatch(/defaultFrom/);
}
it("still accepts an old defaultFrom, but only as a string", () => {
expect(
validateManifest(
host([
{
key: "mode",
type: "boolean",
labelKey: "k",
defaultFrom: "missing",
},
]),
),
).toEqual([]);
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
).join(),
).toMatch(/defaultFrom/);
});
it("accepts defaultable, defaultLevels and personal on host fields", () => {
expect(
validateManifest(
host([
{
key: "mode",
type: "boolean",
labelKey: "k",
defaultable: false,
defaultLevels: ["user", "folder"],
personal: true,
},
]),
),
).toEqual([]);
});
it("rejects bad default options and a defaultable secret", () => {
const errors = (field: Record<string, unknown>) =>
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", ...field }]),
).join();
expect(errors({ defaultable: "yes" })).toMatch(/defaultable/);
expect(errors({ personal: 1 })).toMatch(/personal/);
expect(errors({ defaultLevels: [] })).toMatch(/defaultLevels/);
expect(errors({ defaultLevels: ["host"] })).toMatch(/defaultLevels/);
expect(errors({ type: "secret", defaultable: true })).toMatch(
/cannot be defaultable/,
);
expect(
validateManifest(
withSettings({
user: [{ key: "a", type: "boolean", labelKey: "k", personal: true }],
}),
).join(),
).toMatch(/only valid on host fields/);
});
it("rejects an unknown shareRead level and host options on admin fields", () => {
@@ -24,6 +24,9 @@ vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
vi.mock("../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../utils/logger.js", () => {
const logger = {
debug: vi.fn(),
@@ -127,11 +127,13 @@ describe("core sync entities", () => {
it("registers what core syncs, under the wire names 2.8 used", () => {
expect(listEntityTypes()).toEqual([
"accountProfile",
"hostDefaultsAdmin",
"sshCredentials",
"sharedCredentials",
"sshFolders",
"hosts",
"sharedHosts",
"hostDefaults",
"userPreferences",
"pluginUserSettings",
]);
@@ -168,7 +170,11 @@ describe("core sync entities", () => {
.filter((entity) => entity.singleton)
.map((entity) => entity.type);
expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]);
expect(singletons.sort()).toEqual([
"accountProfile",
"hostDefaultsAdmin",
"userPreferences",
]);
});
it("only sends shared copies and the account one way", () => {
@@ -178,6 +184,7 @@ describe("core sync entities", () => {
expect(readOnly.sort()).toEqual([
"accountProfile",
"hostDefaultsAdmin",
"sharedCredentials",
"sharedHosts",
]);
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({}));
vi.mock("../../plugins/registry.js", () => ({ consume: () => undefined }));
const { exportDefaultValue, importDefaultValue, parseHostDefaultsSyncId } =
await import("../../sync/host-defaults.js");
describe("host defaults over sync", () => {
it("names a row by level, folder and key", () => {
expect(parseHostDefaultsSyncId("u:core.sshPort")).toEqual({
level: "user",
folderSyncId: null,
fullKey: "core.sshPort",
});
expect(parseHostDefaultsSyncId("f:abc-1:term.fontSize")).toEqual({
level: "folder",
folderSyncId: "abc-1",
fullKey: "term.fontSize",
});
expect(parseHostDefaultsSyncId("x:core.sshPort")).toBeNull();
expect(parseHostDefaultsSyncId("f:core.sshPort")).toBeNull();
});
it("sends a credential and jump hosts by syncId and maps them back", async () => {
const out = {
credential: async (id: number) => `cred-${id}`,
host: async (id: number) => (id === 9 ? null : `host-${id}`),
};
const auth = await exportDefaultValue(
"core",
"auth",
{ authType: "credential", credentialId: 4 },
out,
);
expect(auth).toEqual({
authType: "credential",
credentialSyncId: "cred-4",
});
const jumps = await exportDefaultValue(
"core",
"jumpHosts",
[{ hostId: 1 }, { hostId: 9 }],
out,
);
expect(jumps).toEqual([{ hostSyncId: "host-1" }]);
const back = {
credential: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
host: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
};
expect(await importDefaultValue("core", "auth", auth, back)).toEqual({
authType: "credential",
credentialId: 104,
});
expect(await importDefaultValue("core", "jumpHosts", jumps, back)).toEqual([
{ hostId: 101 },
]);
});
it("passes other values through", async () => {
expect(await exportDefaultValue("core", "sshPort", 22)).toBe(22);
expect(
await importDefaultValue("term", "fontSize", 16, {
credential: async () => null,
host: async () => null,
}),
).toBe(16);
});
});
@@ -0,0 +1,311 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type PluginRow = {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
};
const db = vi.hoisted(() => ({
settings: {} as Record<string, string>,
pluginRows: [] as PluginRow[],
defaults: [] as Array<{
scope: Record<string, unknown>;
namespace: string;
key: string;
value: string;
}>,
hosts: [] as Array<Record<string, unknown>>,
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
folderCredential: new Map<string, number>(),
}));
const manifests = vi.hoisted(() => [
{
id: "ssh-terminal",
contributes: {
settings: {
host: {
fields: [
{ key: "fontSize", type: "number", default: 14 },
{ key: "theme", type: "string", default: "termix" },
{ key: "localEcho", type: "select", default: "auto" },
{ key: "linkClickBehavior", type: "select", default: "confirm" },
],
},
},
},
},
{
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
},
},
},
},
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
]);
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => manifests,
}));
vi.mock("../../database/db/schema.js", () => ({
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
}));
vi.mock("../../database/repositories/factory.js", () => {
const scopeKey = (scope: Record<string, unknown>) =>
scope.level === "admin"
? "admin"
: scope.level === "user"
? `u:${scope.userId}`
: `f:${scope.folderId}`;
return {
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings[key] ?? null,
set: async (key: string, value: string) => {
db.settings[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
listByKey: async (pluginId: string, scope: string, key: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
getAll: async (pluginId: string, scope: string, scopeId: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
delete: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
},
}),
createCurrentHostDefaultsRepository: () => ({
listScope: async (scope: Record<string, unknown>) =>
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
apply: async (
scope: Record<string, unknown>,
set: Array<{ namespace: string; key: string; value: string }>,
) => {
for (const entry of set) db.defaults.push({ scope, ...entry });
},
listHosts: async () => db.hosts,
updateHosts: async (
patches: Array<{ id: number; values: Record<string, unknown> }>,
) => {
for (const patch of patches) {
Object.assign(
db.hosts.find((host) => host.id === patch.id)!,
patch.values,
);
}
},
}),
createCurrentHostResolutionRepository: () => ({
findFolderCredentialId: async (_userId: string, folder: string) =>
db.folderCredential.get(folder) ?? null,
}),
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: async () => db.folders }),
}),
},
}),
};
});
const { runHostDefaultsMigration } =
await import("../../upgrade/host-defaults-migration.js");
function level(namespace: string, key: string, scopeLevel: string) {
const row = db.defaults.find(
(entry) =>
entry.namespace === namespace &&
entry.key === key &&
entry.scope.level === scopeLevel,
);
return row ? JSON.parse(row.value) : undefined;
}
function hostValue(hostId: number, pluginId: string, key: string) {
const row = db.pluginRows.find(
(entry) =>
entry.pluginId === pluginId &&
entry.scope === "host" &&
entry.scopeId === String(hostId) &&
entry.key === key,
);
return row?.value ? JSON.parse(row.value) : undefined;
}
const plugin = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: unknown,
): PluginRow => ({
pluginId,
scope,
scopeId,
key,
value: JSON.stringify(value),
});
beforeEach(() => {
db.settings = {};
db.pluginRows = [];
db.defaults = [];
db.hosts = [];
db.folders = [];
db.folderCredential = new Map();
});
describe("runHostDefaultsMigration", () => {
it("moves the old server defaults, dropping the credential and password", async () => {
db.settings.host_defaults = JSON.stringify({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
socks5Password: "secret",
credentialId: 3,
statusCheckEnabled: false,
});
db.pluginRows.push(
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
);
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
await runHostDefaultsMigration();
expect(level("core", "socks5", "admin")).toMatchObject({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
});
expect(JSON.stringify(db.defaults)).not.toContain("secret");
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
expect(level("core", "auth", "folder")).toMatchObject({
authType: "credential",
credentialId: 9,
});
});
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
);
await runHostDefaultsMigration();
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
});
it("writes in the look a host really had while it followed its user", async () => {
db.hosts = [
{ id: 1, userId: "u1", defaultOverrides: null },
{ id: 2, userId: "u1", defaultOverrides: null },
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
];
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("ssh-terminal", "host", "1", "fontSize", 30),
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
plugin("ssh-terminal", "host", "2", "fontSize", 30),
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
);
await runHostDefaultsMigration();
// Followed the user: the user's look, and their echo mode.
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
// Had its own look: kept.
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
// Saved since host defaults: left alone.
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
});
it("gives a credential host its folder's credential", async () => {
db.hosts = [
{
id: 1,
userId: "u1",
authType: "credential",
credentialId: null,
folder: "Prod",
defaultOverrides: null,
},
];
db.folderCredential.set("Prod", 12);
await runHostDefaultsMigration();
expect(db.hosts[0].credentialId).toBe(12);
});
it("moves each level once", async () => {
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
await runHostDefaultsMigration();
db.defaults = [];
await runHostDefaultsMigration();
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
});
});