mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -98,7 +98,6 @@ describe("applyHostEnrollmentDefaults", () => {
|
||||
ip: "server.example",
|
||||
port: 22,
|
||||
authType: "none",
|
||||
enableTerminal: true,
|
||||
enableSsh: true,
|
||||
});
|
||||
});
|
||||
@@ -109,12 +108,12 @@ describe("applyHostEnrollmentDefaults", () => {
|
||||
ip: "server.example",
|
||||
port: 2222,
|
||||
authType: "password",
|
||||
enableTerminal: false,
|
||||
enableSsh: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
port: 2222,
|
||||
authType: "password",
|
||||
enableTerminal: false,
|
||||
enableSsh: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,11 @@ const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
list: vi.fn(),
|
||||
applyPluginSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||
setHostPluginEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
@@ -37,8 +42,12 @@ const host = {
|
||||
port: 22,
|
||||
username: "alice",
|
||||
authType: "none",
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
pluginSettings: {
|
||||
"ssh-terminal": {
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
},
|
||||
},
|
||||
terminalConfig: { macOptionIsMeta: false },
|
||||
};
|
||||
beforeEach(() => {
|
||||
@@ -49,7 +58,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves disabled terminal settings through export selection and import (overwrite=%s)",
|
||||
"carries plugin host settings through export selection and import (overwrite=%s)",
|
||||
async (overwrite) => {
|
||||
const payload = buildExportPayload(
|
||||
{ hosts: [host] },
|
||||
@@ -71,10 +80,10 @@ it.each([false, true])(
|
||||
const write = overwrite ? mocks.update : mocks.create;
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
const saved = write.mock.calls[0].at(-1);
|
||||
expect(saved).toMatchObject({
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
});
|
||||
expect(mocks.applyPluginSettings).toHaveBeenCalledWith(
|
||||
overwrite ? 19 : 71,
|
||||
expect.objectContaining({ pluginSettings: host.pluginSettings }),
|
||||
);
|
||||
expect(JSON.parse(saved.terminalConfig)).toEqual({
|
||||
macOptionIsMeta: false,
|
||||
});
|
||||
|
||||
@@ -291,28 +291,24 @@ describe("stripSensitiveFields", () => {
|
||||
});
|
||||
|
||||
describe("transformHostResponse", () => {
|
||||
it("parses tags and coerces enable flags to booleans", () => {
|
||||
it("parses tags and coerces flags to booleans", () => {
|
||||
const result = transformHostResponse({
|
||||
tags: "a,b,c",
|
||||
enableTerminal: 1,
|
||||
enableTunnel: 0,
|
||||
shareSshAuth: 1,
|
||||
pin: 1,
|
||||
});
|
||||
expect(result.tags).toEqual(["a", "b", "c"]);
|
||||
expect(result.enableTerminal).toBe(true);
|
||||
expect(result.enableTunnel).toBe(false);
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.pin).toBe(true);
|
||||
});
|
||||
|
||||
it("parses JSON array fields and defaults them to []", () => {
|
||||
const result = transformHostResponse({
|
||||
tunnelConnections: '[{"sourcePort":8080}]',
|
||||
jumpHosts: null,
|
||||
jumpHosts: '[{"hostId":8}]',
|
||||
quickActions: null,
|
||||
});
|
||||
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
|
||||
expect(result.jumpHosts).toEqual([]);
|
||||
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
|
||||
expect(result.quickActions).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes the stored SSH switch through", () => {
|
||||
|
||||
@@ -62,6 +62,10 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||
decryptSystemSecret: async (stored: string) => stored,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const registryProviders = new Map<string, unknown>();
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => registryProviders.get(key),
|
||||
@@ -73,6 +77,7 @@ const {
|
||||
withHostPluginSettings,
|
||||
writeHostPluginSettings,
|
||||
applyPluginHostImportSettings,
|
||||
setHostPluginEnabled,
|
||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||
|
||||
function manifest(id: string, host: unknown): PluginManifest {
|
||||
@@ -342,3 +347,88 @@ describe("applyPluginHostImportSettings", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("share levels, legacy fields and generic writes", () => {
|
||||
const DESKTOP = manifest("remote-desktop", {
|
||||
fields: [
|
||||
{ key: "enableRdp", type: "boolean", labelKey: "k" },
|
||||
{
|
||||
key: "guacamoleConfig",
|
||||
type: "json",
|
||||
labelKey: "k",
|
||||
shareRead: "edit",
|
||||
},
|
||||
{ key: "gatewayToken", type: "secret", labelKey: "k" },
|
||||
],
|
||||
});
|
||||
|
||||
it("hides a field above a recipient's share level", () => {
|
||||
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
|
||||
const values = new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
"remote-desktop": { enableRdp: true, guacamoleConfig: { a: 1 } },
|
||||
},
|
||||
],
|
||||
]);
|
||||
const connect = { id: 1, isShared: true, permissionLevel: "connect" };
|
||||
const edit = { id: 1, isShared: true, permissionLevel: "edit" };
|
||||
const owner = { id: 1 };
|
||||
attachHostPluginSettings([connect, edit, owner], values);
|
||||
|
||||
expect(connect).toMatchObject({
|
||||
pluginSettings: { "remote-desktop": { enableRdp: true } },
|
||||
});
|
||||
expect(
|
||||
(connect as { pluginSettings: Record<string, Record<string, unknown>> })
|
||||
.pluginSettings["remote-desktop"],
|
||||
).not.toHaveProperty("guacamoleConfig");
|
||||
expect(edit).toMatchObject({
|
||||
pluginSettings: { "remote-desktop": { guacamoleConfig: { a: 1 } } },
|
||||
});
|
||||
expect(owner).toMatchObject({
|
||||
pluginSettings: { "remote-desktop": { guacamoleConfig: { a: 1 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a plugin's legacy payload fields without overwriting core's", () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
registryProviders.set(
|
||||
"docker.hostPayloadLegacy",
|
||||
(own: Record<string, unknown>) => ({
|
||||
enableDocker: own.enableDocker,
|
||||
name: "overwritten?",
|
||||
}),
|
||||
);
|
||||
const host: Record<string, unknown> = { id: 2, name: "web" };
|
||||
attachHostPluginSettings(
|
||||
[host],
|
||||
new Map([[2, { docker: { enableDocker: true } }]]),
|
||||
);
|
||||
expect(host.enableDocker).toBe(true);
|
||||
expect(host.name).toBe("web");
|
||||
});
|
||||
|
||||
it("imports an export's plugin values, leaving secrets out", async () => {
|
||||
loaded.push({ id: "remote-desktop", manifest: DESKTOP, state: "active" });
|
||||
await applyPluginHostImportSettings(9, {
|
||||
pluginSettings: {
|
||||
"remote-desktop": { enableRdp: true, gatewayToken: { set: true } },
|
||||
},
|
||||
});
|
||||
expect(setCalls).toEqual([
|
||||
["remote-desktop", "host", "9", "enableRdp", "true"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("flips a plugin's host switch for many hosts", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
expect(await setHostPluginEnabled("docker", [3, 4], true)).toBe(true);
|
||||
expect(setCalls.map((call) => [call[2], call[3], call[4]])).toEqual([
|
||||
["3", "enableDocker", "true"],
|
||||
["4", "enableDocker", "true"],
|
||||
]);
|
||||
expect(await setHostPluginEnabled("missing", [3], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /notification-channels/:id/test sends through deliverNotification,
|
||||
* the same path an automation's notify step takes.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
deliver: vi.fn(),
|
||||
channel: null as null | { id: number; type: string; config: string },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = "user-1";
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentNotificationChannelRepository: () => ({
|
||||
findNotificationChannelForUser: async () => h.channel,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
deliverNotification: h.deliver,
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/notification-channels-routes.js");
|
||||
const app = express().use(express.json()).use(router);
|
||||
|
||||
beforeEach(() => {
|
||||
h.deliver.mockReset();
|
||||
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
|
||||
});
|
||||
|
||||
describe("POST /notification-channels/:id/test", () => {
|
||||
it("delivers a test notification to the channel", async () => {
|
||||
h.deliver.mockResolvedValue(undefined);
|
||||
const response = await request(app).post("/notification-channels/3/test");
|
||||
expect(response.body).toEqual({ success: true });
|
||||
expect(h.deliver).toHaveBeenCalledWith(
|
||||
h.channel,
|
||||
expect.objectContaining({ severity: "info" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the delivery error", async () => {
|
||||
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
|
||||
const response = await request(app).post("/notification-channels/3/test");
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: "ntfy channel is missing a topic",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 for a channel the user does not own", async () => {
|
||||
h.channel = null;
|
||||
const response = await request(app).post("/notification-channels/9/test");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* contributes.http.legacyPaths: an old URL under "/<plugin id>/" reaches the
|
||||
* plugin's router with that prefix removed, and nothing else is caught.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const routers = vi.hoisted(() => new Map<string, express.Router>());
|
||||
|
||||
vi.mock("../../../plugins/http.js", () => ({
|
||||
getPluginRouter: (id: string) => routers.get(id),
|
||||
}));
|
||||
|
||||
const { mountPluginLegacyPaths } =
|
||||
await import("../../../database/routes/plugin-api-routes.js");
|
||||
|
||||
function appWith(active: Array<{ id: string; legacyPaths: string[] }>) {
|
||||
const app = express();
|
||||
app.get("/core/route", (_req, res) => res.json({ from: "core" }));
|
||||
mountPluginLegacyPaths(app, () => active);
|
||||
app.use((_req, res) => res.status(404).json({ from: "fallthrough" }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("mountPluginLegacyPaths", () => {
|
||||
const router = express.Router();
|
||||
router.post("/webhook/:token", (req, res) =>
|
||||
res.json({ token: req.params.token }),
|
||||
);
|
||||
routers.set("automations", router);
|
||||
|
||||
it("serves an old URL through the plugin's router", async () => {
|
||||
const app = appWith([
|
||||
{ id: "automations", legacyPaths: ["/automations/webhook"] },
|
||||
]);
|
||||
const response = await request(app).post("/automations/webhook/abc123");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ token: "abc123" });
|
||||
});
|
||||
|
||||
it("leaves paths outside the declared prefixes alone", async () => {
|
||||
const app = appWith([
|
||||
{ id: "automations", legacyPaths: ["/automations/webhook"] },
|
||||
]);
|
||||
expect((await request(app).get("/automations/other")).body).toEqual({
|
||||
from: "fallthrough",
|
||||
});
|
||||
expect((await request(app).get("/core/route")).body).toEqual({
|
||||
from: "core",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 while the plugin serves no router", async () => {
|
||||
const app = appWith([{ id: "gone", legacyPaths: ["/gone/hook"] }]);
|
||||
const response = await request(app).post("/gone/hook/x");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,14 @@ const state = vi.hoisted(() => ({
|
||||
active: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Host plugin settings over remote sync: non-secret values travel with the
|
||||
* host, and a plugin can translate a local row id to its syncId and back.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
stored: new Map<string, unknown>(),
|
||||
writes: [] as Array<[string, number, string, unknown]>,
|
||||
hooks: new Map<string, unknown>(),
|
||||
manifests: [] as PluginManifest[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => h.manifests,
|
||||
}));
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => h.hooks.get(key),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", () => ({
|
||||
declaredFields: (manifest: PluginManifest) =>
|
||||
manifest.contributes!.settings!.host!.fields,
|
||||
getAllSettings: async (
|
||||
manifest: PluginManifest,
|
||||
_scope: string,
|
||||
id: number,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
manifest.contributes!.settings!.host!.fields.map((field) => [
|
||||
field.key,
|
||||
h.stored.get(`${manifest.id}:${id}:${field.key}`),
|
||||
]),
|
||||
),
|
||||
setSetting: async (
|
||||
manifest: PluginManifest,
|
||||
_scope: string,
|
||||
id: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) => {
|
||||
h.writes.push([manifest.id, id, key, value]);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { exportHostPluginSettings, importHostPluginSettings } =
|
||||
await import("../../../database/routes/sync-host-plugin-settings.js");
|
||||
|
||||
const VAULT = {
|
||||
id: "vault",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "profileId", type: "number", labelKey: "k" },
|
||||
{ key: "token", type: "secret", labelKey: "k" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
beforeEach(() => {
|
||||
h.stored.clear();
|
||||
h.writes.length = 0;
|
||||
h.hooks.clear();
|
||||
h.manifests = [VAULT];
|
||||
h.hooks.set("vault.hostSettingsSync", {
|
||||
exportValue: (key: string, value: unknown) =>
|
||||
key === "profileId" && value === 4 ? "profile-sync-4" : value,
|
||||
importValue: (key: string, value: unknown) =>
|
||||
key === "profileId" && value === "profile-sync-4" ? 11 : value,
|
||||
});
|
||||
});
|
||||
|
||||
describe("host plugin settings over sync", () => {
|
||||
it("exports non-secret values through the plugin's translation", async () => {
|
||||
h.stored.set("vault:1:profileId", 4);
|
||||
h.stored.set("vault:1:token", "secret");
|
||||
expect(await exportHostPluginSettings(1)).toEqual({
|
||||
vault: { profileId: "profile-sync-4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("imports them back to local ids", async () => {
|
||||
await importHostPluginSettings(9, {
|
||||
vault: { profileId: "profile-sync-4", token: "ignored" },
|
||||
});
|
||||
expect(h.writes).toEqual([["vault", 9, "profileId", 11]]);
|
||||
});
|
||||
|
||||
it("ignores a payload without plugin settings", async () => {
|
||||
await importHostPluginSettings(9, undefined);
|
||||
expect(h.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user