mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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,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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user