mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: type the plugin host record, move plugin strings out of core and add lint checks
This commit is contained in:
1 parent
bba06f7ff6
commit
b8bbcd2196
626 files changed
+23884
-4590
No files matched your search
@@ -78,6 +78,7 @@ const {
|
||||
writeHostPluginSettings,
|
||||
applyPluginHostImportSettings,
|
||||
setHostPluginEnabled,
|
||||
checkHostPluginSettingsInput,
|
||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||
|
||||
function manifest(id: string, host: unknown): PluginManifest {
|
||||
@@ -453,3 +454,38 @@ describe("share levels, legacy fields and generic writes", () => {
|
||||
expect(await setHostPluginEnabled("missing", [3], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkHostPluginSettingsInput", () => {
|
||||
it("passes declared keys through to the plugin that owns them", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
const result = checkHostPluginSettingsInput({
|
||||
docker: { enableDocker: true, socketPath: "/run/docker.sock" },
|
||||
});
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
expect(result.writes).toEqual([
|
||||
{
|
||||
manifest: DOCKER,
|
||||
values: { enableDocker: true, socketPath: "/run/docker.sock" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a key the plugin does not declare and a value it rejects", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
const { errors } = checkHostPluginSettingsInput({
|
||||
docker: { dockerConfig: {}, enableDocker: "sure" },
|
||||
});
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0]).toContain("docker.dockerConfig");
|
||||
expect(errors[1]).toContain("docker.enableDocker");
|
||||
});
|
||||
|
||||
it("skips a plugin that is not running", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
|
||||
const result = checkHostPluginSettingsInput({
|
||||
docker: { enableDocker: true },
|
||||
});
|
||||
expect(result).toEqual({ writes: [], skipped: ["docker"], errors: [] });
|
||||
});
|
||||
});
|
||||
@@ -443,6 +443,13 @@ ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_i
|
||||
CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);
|
||||
|
||||
-- ==== core 0064_flippant_killer_shrike ====
|
||||
-- ssh_data.autostart_password, autostart_key and autostart_key_password held
|
||||
-- copies of a host's login for 2.8's autostart endpoint, which nothing has read
|
||||
-- since. They leave the schema but stay in place, unused, until 3.0.0, so no
|
||||
-- install loses what it stored.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -462,6 +462,13 @@ CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_aut
|
||||
CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id");
|
||||
|
||||
-- ==== core 0064_sticky_pestilence ====
|
||||
-- ssh_data.autostart_password, autostart_key and autostart_key_password held
|
||||
-- copies of a host's login for 2.8's autostart endpoint, which nothing has read
|
||||
-- since. They leave the schema but stay in place, unused, until 3.0.0, so no
|
||||
-- install loses what it stored.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -112,9 +112,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
sudoPassword: "owner-sudo",
|
||||
autostartPassword: "auto-pass",
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
terminalConfig: null,
|
||||
@@ -519,7 +516,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.username).toBe("cred-user");
|
||||
// Owner-only operational secrets are NOT stripped for the admin.
|
||||
expect(host.sudoPassword).toBe("owner-sudo");
|
||||
expect(host.autostartPassword).toBe("auto-pass");
|
||||
});
|
||||
|
||||
it("audits every admin-bypass host resolution", async () => {
|
||||
|
||||
@@ -140,11 +140,40 @@ vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
row: Record<string, unknown>,
|
||||
loadHostPluginSettings: async (hostIds: number[]) =>
|
||||
new Map(
|
||||
hostIds.map((id) => [
|
||||
id,
|
||||
{ fixture: { mine: `own-${id}` }, other: { theirs: true } },
|
||||
]),
|
||||
),
|
||||
checkHostPluginSettingsInput: (
|
||||
input: Record<string, Record<string, unknown>>,
|
||||
) => {
|
||||
h.importRows.push({ hostId, row });
|
||||
const errors: string[] = [];
|
||||
const writes: Array<{
|
||||
manifest: { id: string };
|
||||
values: Record<string, unknown>;
|
||||
}> = [];
|
||||
const skipped: string[] = [];
|
||||
for (const [pluginId, values] of Object.entries(input)) {
|
||||
if (pluginId === "off") {
|
||||
skipped.push(pluginId);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(values)) {
|
||||
if (key === "undeclared") errors.push(`${pluginId}.${key}`);
|
||||
}
|
||||
writes.push({ manifest: { id: pluginId }, values });
|
||||
}
|
||||
return { writes, skipped, errors };
|
||||
},
|
||||
writeHostPluginSettings: async (
|
||||
manifest: { id: string },
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
) => {
|
||||
h.importRows.push({ hostId, row: { [manifest.id]: values } });
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
@@ -361,6 +390,7 @@ describe("ctx.hosts", () => {
|
||||
|
||||
it("creates a host once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.importRows = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
@@ -371,19 +401,92 @@ describe("ctx.hosts", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
enableRdp: true,
|
||||
tags: ["a", "b"],
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
pluginSettings: { "remote-desktop": { enableRdp: true } },
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
name: "pve-guest",
|
||||
userId: "user-1",
|
||||
tags: "a,b",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
pluginSettings: { mine: "own-99" },
|
||||
});
|
||||
expect(h.created).toHaveLength(1);
|
||||
// Another plugin's fields go through its import normalizer.
|
||||
expect(h.importRows.at(-1)).toMatchObject({
|
||||
hostId: 99,
|
||||
row: { enableRdp: true },
|
||||
expect(h.created[0]).toMatchObject({
|
||||
tags: "a,b",
|
||||
jumpHosts: '[{"hostId":3}]',
|
||||
});
|
||||
// Another plugin's settings go to that plugin, validated by core.
|
||||
expect(h.importRows).toEqual([
|
||||
{ hostId: 99, row: { "remote-desktop": { enableRdp: true } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(
|
||||
hosts.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
enableRdp: true,
|
||||
} as never),
|
||||
).rejects.toThrow(/enableRdp/);
|
||||
await expect(
|
||||
hosts.update(5, { userId: "someone-else" } as never),
|
||||
).rejects.toThrow(/userId/);
|
||||
await expect(
|
||||
hosts.update(5, { pluginSettings: { docker: { undeclared: 1 } } }),
|
||||
).rejects.toThrow(/undeclared/);
|
||||
expect(h.created).toHaveLength(0);
|
||||
expect(h.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("builds the record field by field, with no secret and no other plugin's settings", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.ownedHosts = [
|
||||
{
|
||||
id: 4,
|
||||
userId: "user-1",
|
||||
name: "own",
|
||||
ip: "10.0.0.4",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
key: "PRIVATE",
|
||||
autostartPassword: "x",
|
||||
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
|
||||
enableDocker: true,
|
||||
jumpHosts: "[]",
|
||||
},
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const [record] = await hosts.listOwned();
|
||||
const loose = record as unknown as Record<string, unknown>;
|
||||
for (const field of [
|
||||
"password",
|
||||
"key",
|
||||
"autostartPassword",
|
||||
"terminalConfig",
|
||||
"enableDocker",
|
||||
]) {
|
||||
expect(loose).not.toHaveProperty(field);
|
||||
}
|
||||
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
|
||||
expect(record.pluginSettings).toEqual({ mine: "own-4" });
|
||||
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
|
||||
expect(record.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
it("updates a host once granted hosts:write", async () => {
|
||||
@@ -398,6 +501,7 @@ describe("ctx.hosts", () => {
|
||||
expect(h.updates).toEqual([
|
||||
{ userId: "user-1", hostId: 5, patch: { name: "new" } },
|
||||
]);
|
||||
expect(updated?.pluginSettings).toEqual({ mine: "own-5" });
|
||||
});
|
||||
|
||||
it("returns null updating a host that does not exist for this user", async () => {
|
||||
|
||||
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
|
||||
keyPassword: "s3cret-value",
|
||||
sudoPassword: "s3cret-value",
|
||||
socks5Password: "s3cret-value",
|
||||
autostartPassword: "s3cret-value",
|
||||
autostartKey: "s3cret-value",
|
||||
autostartKeyPassword: "s3cret-value",
|
||||
terminalConfig: { sudoPassword: "s3cret-value", fontSize: 14 },
|
||||
socks5ProxyChain: [{ host: "p", password: "s3cret-value" }],
|
||||
};
|
||||
@@ -98,7 +95,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async () => {},
|
||||
loadHostPluginSettings: async () => new Map(),
|
||||
}));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number) => secretHost(id),
|
||||
|
||||
@@ -36,9 +36,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
key: "k",
|
||||
key_password: "kp",
|
||||
key_type: "ed25519",
|
||||
autostart_password: "ap",
|
||||
autostart_key: "ak",
|
||||
autostart_key_password: "akp",
|
||||
sudo_password: "sp",
|
||||
});
|
||||
store.updateCredentialSensitiveFields(13, {
|
||||
@@ -56,17 +53,7 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE ssh_credentials"),
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
"p",
|
||||
"k",
|
||||
"kp",
|
||||
"ed25519",
|
||||
"ap",
|
||||
"ak",
|
||||
"akp",
|
||||
"sp",
|
||||
12,
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith("p", "k", "kp", "ed25519", "sp", 12);
|
||||
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user