mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
37c015a760
commit
c381955ed0
43 files changed
+835
-1999
No files matched your search
@@ -337,24 +337,6 @@ describe("transformHostResponse", () => {
|
||||
expect(result.vncPort).toBe(5900);
|
||||
expect(result.telnetPort).toBe(23);
|
||||
});
|
||||
|
||||
it("coerces enableProxmox and parses proxmoxConfig", () => {
|
||||
const result = transformHostResponse({
|
||||
enableProxmox: 1,
|
||||
proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}',
|
||||
});
|
||||
expect(result.enableProxmox).toBe(true);
|
||||
expect(result.proxmoxConfig).toEqual({
|
||||
defaultCredentialId: 3,
|
||||
windowsPatterns: "win",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults enableProxmox to false when absent", () => {
|
||||
const result = transformHostResponse({ port: 22 });
|
||||
expect(result.enableProxmox).toBe(false);
|
||||
expect(result.proxmoxConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHostForRecipient", () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ const h = vi.hoisted(() => ({
|
||||
ownerId: "user-1",
|
||||
hostById: null as Record<string, unknown> | null,
|
||||
upserts: [] as Array<Record<string, unknown>>,
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
updates: [] as Array<Record<string, unknown>>,
|
||||
updateResult: null as Record<string, unknown> | null,
|
||||
users: [] as Array<{ id: string; username: string }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
@@ -66,6 +69,26 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
host: Record<string, unknown>,
|
||||
) => {
|
||||
const created = { id: 99, ...host, userId };
|
||||
h.created.push(created);
|
||||
return created;
|
||||
},
|
||||
updateEncryptedForUser: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
h.updates.push({ userId, hostId, patch });
|
||||
if (!h.updateResult) return null;
|
||||
return { ...h.updateResult, ...patch, id: hostId, userId };
|
||||
},
|
||||
listDecryptedByUserId: async () => h.ownedHosts,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
@@ -98,6 +121,9 @@ beforeEach(() => {
|
||||
h.ownerId = "user-1";
|
||||
h.hostById = null;
|
||||
h.upserts = [];
|
||||
h.created = [];
|
||||
h.updates = [];
|
||||
h.updateResult = null;
|
||||
h.users = [];
|
||||
h.roles = [];
|
||||
});
|
||||
@@ -241,4 +267,83 @@ describe("ctx.hosts", () => {
|
||||
{ id: 2, name: "auditor", displayName: "Auditor" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses create/update/listOwned without hosts:write", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(
|
||||
hosts.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.update(1, { name: "x" })).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.listOwned()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_create",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a host once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const created = await hosts.create({
|
||||
name: "pve-guest",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
name: "pve-guest",
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(h.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates a host once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.updateResult = { id: 5, name: "old" };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const updated = await hosts.update(5, { name: "new" });
|
||||
expect(updated).toMatchObject({ id: 5, name: "new" });
|
||||
expect(h.updates).toEqual([
|
||||
{ userId: "user-1", hostId: 5, patch: { name: "new" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null updating a host that does not exist for this user", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.update(404, { name: "x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists owned hosts decrypted once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.ownedHosts = [{ id: 1, userId: "user-1", name: "own" }];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The Proxmox host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host that already had Proxmox discovery or
|
||||
* stats configured must keep that configuration once the ssh_data columns
|
||||
* are dropped. Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_proxmox: boolean;
|
||||
proxmox_config: string | null;
|
||||
enable_proxmox_stats: boolean;
|
||||
proxmox_stats_config: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_proxmox ||
|
||||
row.proxmox_config !== null ||
|
||||
row.enable_proxmox_stats ||
|
||||
row.proxmox_stats_config !== null,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runProxmoxSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/proxmox-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runProxmoxSettingsMigration", () => {
|
||||
it("moves an enabled host's columns into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_proxmox: true,
|
||||
proxmox_config: JSON.stringify({ defaultAuthType: "password" }),
|
||||
enable_proxmox_stats: true,
|
||||
proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }),
|
||||
});
|
||||
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableProxmox")).toBe(true);
|
||||
expect(storedValue(1, "proxmoxConfig")).toEqual({
|
||||
defaultAuthType: "password",
|
||||
});
|
||||
expect(storedValue(1, "enableProxmoxStats")).toBe(true);
|
||||
expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" });
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install with no proxmox hosts", async () => {
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({
|
||||
id: 2,
|
||||
enable_proxmox: false,
|
||||
proxmox_config: JSON.stringify({ autoSyncEnabled: true }),
|
||||
enable_proxmox_stats: false,
|
||||
proxmox_stats_config: null,
|
||||
});
|
||||
|
||||
await runProxmoxSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("moves each host independently", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_proxmox: true,
|
||||
proxmox_config: null,
|
||||
enable_proxmox_stats: false,
|
||||
proxmox_stats_config: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_proxmox: false,
|
||||
proxmox_config: null,
|
||||
enable_proxmox_stats: true,
|
||||
proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(2);
|
||||
expect(storedValue(1, "enableProxmox")).toBe(true);
|
||||
expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user