feat: type the plugin host record, move plugin strings out of core and add lint checks

This commit is contained in:
LukeGus committed 2026-09-29 13:08:39 -05:00
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: [] });
});
});
+7
View File
@@ -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 () => {
+114 -10
View File
@@ -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);
});