mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -48,14 +48,17 @@ describe("the encrypted-export ssh_data DDL agrees with its INSERT", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the web endpoint columns in both halves", () => {
|
||||
// Named explicitly rather than derived: the generic check above passes if
|
||||
// BOTH halves omit a column, which is precisely how this feature's columns
|
||||
// would go missing.
|
||||
for (const column of ["enable_web_ui", "web_ui_config"]) {
|
||||
expect(createBody).toContain(column);
|
||||
expect(insertMatch?.[1]).toContain(column);
|
||||
it("leaves plugin host settings to their own table", () => {
|
||||
// Moved columns must not creep back: plugins own these values now.
|
||||
for (const column of [
|
||||
"enable_web_ui",
|
||||
"web_ui_config",
|
||||
"enable_terminal",
|
||||
]) {
|
||||
expect(createBody).not.toContain(column);
|
||||
expect(insertMatch?.[1]).not.toContain(column);
|
||||
}
|
||||
expect(text).toContain("CREATE TABLE plugin_settings (");
|
||||
});
|
||||
|
||||
it("binds exactly one placeholder per column", () => {
|
||||
@@ -76,8 +79,8 @@ describe("the encrypted-export ssh_data DDL agrees with its INSERT", () => {
|
||||
expect((values ?? "").split("?").length - 1).toBe(columns.length);
|
||||
});
|
||||
|
||||
it("carries the web endpoint values in the bound parameter list", () => {
|
||||
expect(text).toContain("decrypted.enableWebUi");
|
||||
expect(text).toContain("decrypted.webUiConfig");
|
||||
it("copies non-secret host plugin settings into the export", () => {
|
||||
expect(text).toContain("INSERT INTO plugin_settings");
|
||||
expect(text).toContain("if (row.encrypted) continue;");
|
||||
});
|
||||
});
|
||||
@@ -243,29 +243,6 @@ describe("HostResolutionRepository", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
|
||||
Buffer.from(`${userId}-key`),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listHostsWithTunnelConnections();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 3]);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-1-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 3 }),
|
||||
"user-2",
|
||||
Buffer.from("user-2-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips owner-scoped host list rows when that user's data is locked", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
|
||||
userId === "user-1" ? Buffer.from("user-1-key") : null,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+1247
File diff suppressed because it is too large.
Load diff
@@ -1,215 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const commandMocks = vi.hoisted(() => ({
|
||||
execBuffer: vi.fn(),
|
||||
execWithSudoBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../../hosts/file-manager/session.js")
|
||||
>()),
|
||||
execBuffer: commandMocks.execBuffer,
|
||||
execWithSudoBuffer: commandMocks.execWithSudoBuffer,
|
||||
}));
|
||||
|
||||
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
|
||||
|
||||
type RouteHandler = (request: Request, response: Response) => Promise<unknown>;
|
||||
|
||||
function setupReadRoute() {
|
||||
const routes = new Map<string, RouteHandler>();
|
||||
const app = {
|
||||
get: (path: string, handler: RouteHandler) => routes.set(path, handler),
|
||||
post: vi.fn(),
|
||||
} as unknown as Express;
|
||||
const session = {
|
||||
isConnected: true,
|
||||
lastActive: 0,
|
||||
sudoPassword: "sudo-secret",
|
||||
} as SSHSession;
|
||||
|
||||
registerFileContentRoutes(app, {
|
||||
sshSessions: { session: session },
|
||||
verifySessionOwnership: () => true,
|
||||
});
|
||||
|
||||
const handler = routes.get("/ssh/file_manager/ssh/readFile");
|
||||
if (!handler) throw new Error("readFile route was not registered");
|
||||
|
||||
const request = {
|
||||
query: { sessionId: "session", path: "/root/secret.txt" },
|
||||
userId: "user",
|
||||
} as unknown as Request;
|
||||
const response = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
vi.mocked(response.status).mockReturnValue(response);
|
||||
vi.mocked(response.json).mockReturnValue(response);
|
||||
|
||||
return { handler, request, response, session };
|
||||
}
|
||||
|
||||
describe("file manager readFile", () => {
|
||||
beforeEach(() => {
|
||||
commandMocks.execBuffer.mockReset();
|
||||
commandMocks.execWithSudoBuffer.mockReset();
|
||||
});
|
||||
|
||||
it("falls back to sudo for both the size check and content read", async () => {
|
||||
const { handler, request, response, session } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
});
|
||||
commandMocks.execWithSudoBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("6\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("secret"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
session,
|
||||
"stat -c%s '/root/secret.txt'",
|
||||
"sudo-secret",
|
||||
);
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
"sudo-secret",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
content: "secret",
|
||||
path: "/root/secret.txt",
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a file that grows beyond the limit while being read", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Command output exceeds 524288000 bytes",
|
||||
code: 1,
|
||||
exceededLimit: true,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxSize: 500 * 1024 * 1024,
|
||||
tooLarge: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps readable files on the unprivileged path", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("hello"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execWithSudoBuffer).not.toHaveBeenCalled();
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
content: "hello",
|
||||
path: "/root/secret.txt",
|
||||
encoding: "utf8",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves percent escapes that are literal path characters", async () => {
|
||||
const { handler, request, response, session } = setupReadRoute();
|
||||
request.query.path = "/tmp/100%/literal%2Fname";
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("hello"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/tmp/100%/literal%2Fname'",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/tmp/100%/literal%2Fname" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized sudo-only files before reading their content", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
});
|
||||
commandMocks.execWithSudoBuffer.mockResolvedValueOnce({
|
||||
stdout: Buffer.from(String(501 * 1024 * 1024)),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tooLarge: true }),
|
||||
);
|
||||
expect(commandMocks.execBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSessionSftp: vi.fn(),
|
||||
execChannel: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../hosts/file-manager/session.js", () => ({
|
||||
...mocks,
|
||||
execWithSudo: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({}) },
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
import { registerFileOperationRoutes } from "../../../hosts/file-manager/operation-routes.js";
|
||||
|
||||
type Handler = (req: Request, res: Response) => Promise<unknown>;
|
||||
function setup(owned = true) {
|
||||
const routes = new Map<string, Handler>();
|
||||
const app = {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: (url: string, fn: Handler) => routes.set(url, fn),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Express;
|
||||
registerFileOperationRoutes(app, {
|
||||
sshSessions: { test: { isConnected: true } as SSHSession },
|
||||
verifySessionOwnership: () => owned,
|
||||
});
|
||||
const res = { status: vi.fn(), json: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
const req = {
|
||||
userId: "owner",
|
||||
body: { sessionId: "test", path: "/tmp", folderName: "parent/child" },
|
||||
} as unknown as Request;
|
||||
return {
|
||||
run: () =>
|
||||
routes.get("/ssh/file_manager/ssh/createFolder")!(
|
||||
req,
|
||||
res as unknown as Response,
|
||||
),
|
||||
res,
|
||||
};
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("creates nested folders over SFTP when the server cannot execute shell commands", async () => {
|
||||
const mkdir = vi.fn((path, _attrs, cb) =>
|
||||
cb(path === "/tmp" ? { code: 4 } : null),
|
||||
);
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
mkdir,
|
||||
stat: (
|
||||
_path: string,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(null, { isDirectory: () => true }),
|
||||
});
|
||||
const { run, res } = setup();
|
||||
await run();
|
||||
expect(mkdir.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/tmp",
|
||||
"/tmp/parent",
|
||||
"/tmp/parent/child",
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/tmp/parent/child" }),
|
||||
);
|
||||
expect(mocks.execChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports permission denial instead of success", async () => {
|
||||
const error = Object.assign(new Error("Permission denied"), { code: 3 });
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
mkdir: (
|
||||
_p: string,
|
||||
_a: unknown,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(error),
|
||||
stat: (
|
||||
_p: string,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(error),
|
||||
});
|
||||
const { run, res } = setup();
|
||||
await run();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
it("rejects a session belonging to another user before opening SFTP", async () => {
|
||||
const { run, res } = setup(false);
|
||||
await run();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(mocks.getSessionSftp).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDirectProbeCommand,
|
||||
buildDirectRsyncCommand,
|
||||
quoteShell,
|
||||
shouldBenchmarkDirectTransfer,
|
||||
shouldUseDirectTransfer,
|
||||
} from "../../../hosts/file-manager/direct-transfer-routing.js";
|
||||
|
||||
const endpoint = { host: "10.0.0.2", port: 2222, username: "deploy" };
|
||||
|
||||
describe("direct transfer routing", () => {
|
||||
it("keeps small transfers on the relay without benchmarking", () => {
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024 - 1)).toBe(false);
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a meaningful speed advantage", () => {
|
||||
expect(shouldUseDirectTransfer(700, 1000)).toBe(true);
|
||||
expect(shouldUseDirectTransfer(850, 1000)).toBe(false);
|
||||
expect(shouldUseDirectTransfer(0, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("probes without accepting passwords or unknown host keys", () => {
|
||||
const command = buildDirectProbeCommand(endpoint);
|
||||
expect(command).toContain("BatchMode=yes");
|
||||
expect(command).toContain("StrictHostKeyChecking=yes");
|
||||
expect(command).toContain("ConnectTimeout=5");
|
||||
expect(command).toContain("-p 2222");
|
||||
});
|
||||
|
||||
it("quotes source and destination paths for rsync", () => {
|
||||
const command = buildDirectRsyncCommand(
|
||||
endpoint,
|
||||
["/srv/a file", "/srv/it's-safe"],
|
||||
"/opt/releases",
|
||||
true,
|
||||
);
|
||||
expect(command).toContain("--partial --append-verify");
|
||||
expect(command).toContain("--protect-args");
|
||||
expect(command).toContain(quoteShell("/srv/a file"));
|
||||
expect(command).toContain(quoteShell("/srv/it's-safe"));
|
||||
expect(command).toContain("deploy@10.0.0.2:/opt/releases/");
|
||||
});
|
||||
|
||||
it("brackets IPv6 destinations", () => {
|
||||
const command = buildDirectProbeCommand({
|
||||
host: "2001:db8::2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
});
|
||||
expect(command).toContain("root@[2001:db8::2]");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDeleteCommand } from "../../../hosts/file-manager/operation-commands.js";
|
||||
|
||||
describe("buildDeleteCommand", () => {
|
||||
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
|
||||
const command = buildDeleteCommand(
|
||||
"/C:/Users/Administrator/test.txt",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
);
|
||||
expect(command.commandWithSuccess).not.toContain("&&");
|
||||
});
|
||||
|
||||
it("adds recursive deletion for Windows directories", () => {
|
||||
const command = buildDeleteCommand("C:/Temp/Folder", true);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes single quotes in Windows literal paths", () => {
|
||||
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps POSIX delete commands using shell success chaining", () => {
|
||||
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command} && echo "SUCCESS"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ChannelOpenSerializer,
|
||||
execBuffer,
|
||||
execWithSudoBuffer,
|
||||
type SSHSession,
|
||||
} from "../../../hosts/file-manager/session.js";
|
||||
|
||||
function setupSession() {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = vi.fn();
|
||||
stream.write = vi.fn();
|
||||
|
||||
const exec = vi.fn(
|
||||
(
|
||||
_command: string,
|
||||
callback: (error: undefined, channel: typeof stream) => void,
|
||||
) => callback(undefined, stream),
|
||||
);
|
||||
const session = {
|
||||
client: { exec },
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
} as unknown as SSHSession;
|
||||
|
||||
return { exec, session, stream };
|
||||
}
|
||||
|
||||
describe("file-manager command helpers", () => {
|
||||
it("writes the sudo password to stdin instead of the command line", async () => {
|
||||
const { exec, session, stream } = setupSession();
|
||||
const resultPromise = execWithSudoBuffer(
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
"private password",
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(stream.write).toHaveBeenCalled());
|
||||
expect(exec).toHaveBeenCalledWith(
|
||||
"sudo -S -p '' cat '/root/secret.txt' 2>&1",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(exec.mock.calls[0][0]).not.toContain("private password");
|
||||
expect(stream.write).toHaveBeenCalledWith("private password\n");
|
||||
|
||||
stream.emit("close", 0);
|
||||
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
|
||||
});
|
||||
|
||||
it("closes the channel when stdout exceeds the configured limit", async () => {
|
||||
const { session, stream } = setupSession();
|
||||
const resultPromise = execBuffer(session, "cat file", 4);
|
||||
|
||||
await vi.waitFor(() => expect(stream.listenerCount("data")).toBe(1));
|
||||
stream.emit("data", Buffer.from("12345"));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
code: 1,
|
||||
exceededLimit: true,
|
||||
});
|
||||
expect(stream.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hashSftpFile,
|
||||
verifySftpFileIntegrity,
|
||||
} from "../../../hosts/file-manager/transfer-integrity.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
function fakeSftp(contents: Record<string, Buffer | Buffer[]>): SFTPWrapper {
|
||||
return {
|
||||
createReadStream: vi.fn((path: string) => {
|
||||
const value = contents[path];
|
||||
if (value === undefined) {
|
||||
return new Readable({
|
||||
read() {
|
||||
this.destroy(new Error(`Missing file: ${path}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Readable.from(Array.isArray(value) ? value : [value]);
|
||||
}),
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
describe("transfer integrity", () => {
|
||||
it("hashes all chunks in an SFTP file", async () => {
|
||||
const sftp = fakeSftp({
|
||||
"/file": [Buffer.from("hello "), Buffer.from("world")],
|
||||
});
|
||||
|
||||
await expect(hashSftpFile(sftp, "/file")).resolves.toBe(
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts matching source and destination files", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("same bytes") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("same bytes") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).resolves.toMatchObject({ algorithm: "sha256" });
|
||||
});
|
||||
|
||||
it("rejects a corrupted destination", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("expected") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("corrupted") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).rejects.toThrow("SHA-256 verification failed for /source");
|
||||
});
|
||||
|
||||
it("aborts hashing with the caller's cancellation error", async () => {
|
||||
const sftp = fakeSftp({ "/file": Buffer.from("data") });
|
||||
const cancelled = new Error("cancelled by test");
|
||||
|
||||
await expect(
|
||||
hashSftpFile(
|
||||
sftp,
|
||||
"/file",
|
||||
() => true,
|
||||
() => cancelled,
|
||||
),
|
||||
).rejects.toBe(cancelled);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateIncompressibleSample,
|
||||
resolveArchiveTransferMethod,
|
||||
type TransferScanSummary,
|
||||
} from "../../../hosts/file-manager/transfer-routing.js";
|
||||
|
||||
describe("transfer content sampling", () => {
|
||||
it("recognizes repetitive content as compressible", () => {
|
||||
expect(estimateIncompressibleSample(Buffer.alloc(64 * 1024, 65))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes high-entropy content as incompressible", () => {
|
||||
const sample = Buffer.alloc(64 * 1024);
|
||||
let state = 0x12345678;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
sample[i] = state & 0xff;
|
||||
}
|
||||
expect(estimateIncompressibleSample(sample)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers sampled content over a misleading extension", () => {
|
||||
const summary: TransferScanSummary = {
|
||||
fileCount: 120,
|
||||
totalBytes: 1024 * 1024 * 1024,
|
||||
largestFileBytes: 32 * 1024 * 1024,
|
||||
incompressibleRatio: 0,
|
||||
sampledIncompressibleRatio: 1,
|
||||
};
|
||||
expect(
|
||||
resolveArchiveTransferMethod("auto", summary, "unix", "unix", true, true),
|
||||
).toBe("item_sftp");
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clearTransferProfiles,
|
||||
flushTransferProfiles,
|
||||
getRecentDirectRouteDecision,
|
||||
getTransferProfile,
|
||||
initializeTransferProfiles,
|
||||
recordDirectRouteBenchmark,
|
||||
recordDirectRouteOutcome,
|
||||
recordTransferProfile,
|
||||
selectTransferTuning,
|
||||
updateTransferProfile,
|
||||
} from "../../../hosts/file-manager/transfer-tuning.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe("adaptive transfer tuning", () => {
|
||||
beforeEach(clearTransferProfiles);
|
||||
|
||||
it("keeps small files on one conservative lane", () => {
|
||||
expect(selectTransferTuning(8 * MB)).toEqual({
|
||||
parallelSegmentCount: 1,
|
||||
pipelineConcurrency: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses more lanes for large transfers without exceeding segment count", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB)).toEqual({
|
||||
parallelSegmentCount: 4,
|
||||
pipelineConcurrency: 32,
|
||||
});
|
||||
expect(selectTransferTuning(300 * MB).parallelSegmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it("honours an explicit lane selection", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB, undefined, 3)).toMatchObject({
|
||||
parallelSegmentCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("backs off after failures", () => {
|
||||
const profile = updateTransferProfile(undefined, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 1000,
|
||||
lanes: 4,
|
||||
pipelineConcurrency: 32,
|
||||
failed: true,
|
||||
now: 1,
|
||||
});
|
||||
expect(profile.preferredLanes).toBe(2);
|
||||
expect(profile.pipelineConcurrency).toBe(16);
|
||||
});
|
||||
|
||||
it("learns and expires host-pair profiles", () => {
|
||||
recordTransferProfile("a->b", {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 32,
|
||||
failed: false,
|
||||
now: 100,
|
||||
});
|
||||
expect(getTransferProfile("a->b", 101)?.samples).toBe(1);
|
||||
expect(getTransferProfile("a->b", 8 * 24 * 60 * 60 * 1000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses recent route benchmarks and cools down failed direct paths", () => {
|
||||
recordDirectRouteBenchmark("a->b", 700, 1000, 100);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 101)).toEqual({
|
||||
useDirect: true,
|
||||
directMs: 700,
|
||||
relayMs: 1000,
|
||||
});
|
||||
|
||||
recordDirectRouteOutcome("a->b", true, 102, 500);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 103)?.useDirect).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 1_103)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists only anonymous local profiles across restarts", async () => {
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
const dataDir = await fs.mkdtemp(path.join(tmpdir(), "termix-transfer-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const rawKey = "root@10.0.0.1->deploy@10.0.0.2";
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await initializeTransferProfiles();
|
||||
recordTransferProfile(rawKey, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 16,
|
||||
failed: false,
|
||||
now,
|
||||
});
|
||||
recordDirectRouteBenchmark(rawKey, 700, 1000, now);
|
||||
await flushTransferProfiles();
|
||||
|
||||
const stored = await fs.readFile(
|
||||
path.join(dataDir, "adaptive-transfer-profiles.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(stored).not.toContain(rawKey);
|
||||
expect(Object.keys(JSON.parse(stored).profiles)[0]).toMatch(
|
||||
/^[a-f0-9]{64}$/,
|
||||
);
|
||||
|
||||
clearTransferProfiles();
|
||||
await initializeTransferProfiles();
|
||||
expect(getTransferProfile(rawKey, now + 1)).toMatchObject({ samples: 1 });
|
||||
expect(
|
||||
getRecentDirectRouteDecision(rawKey, 1_000, now + 1),
|
||||
).toMatchObject({ useDirect: true });
|
||||
} finally {
|
||||
clearTransferProfiles();
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper } from "ssh2";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSafeTrashSource,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "../../../hosts/file-manager/trash-service.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
// Windows only allows symlink creation with elevation or Developer Mode, so the
|
||||
// symlink safety test is skipped where the OS refuses to create one at all.
|
||||
const canCreateSymlinks = (() => {
|
||||
const probe = fs.mkdtempSync(path.join(os.tmpdir(), "termix-symlink-probe-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(probe, "target"));
|
||||
fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link"), "dir");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
|
||||
function localSftp(home: string): SFTPWrapper {
|
||||
const callback = <T>(
|
||||
promise: Promise<T>,
|
||||
done: (error: Error | undefined, value?: T) => void,
|
||||
) =>
|
||||
promise
|
||||
.then((value) => done(undefined, value))
|
||||
.catch((error) => done(error));
|
||||
return {
|
||||
realpath(
|
||||
_target: string,
|
||||
done: (error: Error | undefined, value?: string) => void,
|
||||
) {
|
||||
done(undefined, home);
|
||||
},
|
||||
stat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.stat(target), done);
|
||||
},
|
||||
lstat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.lstat(target), done);
|
||||
},
|
||||
readdir(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: unknown[]) => void,
|
||||
) {
|
||||
callback(
|
||||
fs.promises.readdir(target, { withFileTypes: true }).then((entries) =>
|
||||
entries.map((entry) => ({
|
||||
filename: entry.name,
|
||||
longname: entry.name,
|
||||
attrs: {},
|
||||
})),
|
||||
),
|
||||
done,
|
||||
);
|
||||
},
|
||||
readFile(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: Buffer) => void,
|
||||
) {
|
||||
callback(fs.promises.readFile(target), done);
|
||||
},
|
||||
writeFile(target: string, data: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.writeFile(target, data)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rename(from: string, to: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rename(from, to)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
unlink(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.unlink(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
mkdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.mkdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rmdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rmdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const home = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "termix-trash-"),
|
||||
);
|
||||
temporaryDirectories.push(home);
|
||||
return { home, sftp: localSftp(home) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) =>
|
||||
fs.promises.rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("file manager trash safety", () => {
|
||||
it("rejects roots and paths inside the trash", () => {
|
||||
expect(isSafeTrashSource("/", "/home/user/.termix-trash")).toBe(false);
|
||||
expect(isSafeTrashSource("C:/", "C:/Users/user/.termix-trash")).toBe(false);
|
||||
expect(
|
||||
isSafeTrashSource(
|
||||
"/home/user/.termix-trash/files/a",
|
||||
"/home/user/.termix-trash",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts ordinary files and directories", () => {
|
||||
expect(
|
||||
isSafeTrashSource("/home/user/report.txt", "/home/user/.termix-trash"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("moves, lists, and restores a file without changing its contents", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "report.txt");
|
||||
await fs.promises.writeFile(original, "important");
|
||||
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
expect(fs.existsSync(original)).toBe(false);
|
||||
expect(await listTrash(sftp, 7)).toEqual([trashed]);
|
||||
|
||||
await restoreTrashItem(sftp, trashed.id);
|
||||
expect(await fs.promises.readFile(original, "utf8")).toBe("important");
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it("permanently deletes only the stored trash path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "folder");
|
||||
await fs.promises.mkdir(original);
|
||||
await fs.promises.writeFile(path.join(original, "nested.txt"), "data");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(!canCreateSymlinks)(
|
||||
"does not follow directory symlinks during permanent deletion",
|
||||
async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const target = path.join(home, "target");
|
||||
const link = path.join(home, "link");
|
||||
await fs.promises.mkdir(target);
|
||||
await fs.promises.writeFile(path.join(target, "keep.txt"), "keep");
|
||||
await fs.promises.symlink(target, link, "dir");
|
||||
|
||||
const trashed = await moveToTrash(sftp, link);
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(target, "keep.txt"), "utf8"),
|
||||
).toBe("keep");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses tampered metadata instead of deleting an arbitrary path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "discard.txt");
|
||||
const protectedFile = path.join(home, "keep.txt");
|
||||
await fs.promises.writeFile(original, "discard");
|
||||
await fs.promises.writeFile(protectedFile, "keep");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.trashPath = protectedFile;
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
await expect(permanentlyDeleteTrashItem(sftp, trashed.id)).rejects.toThrow(
|
||||
"Invalid trash metadata",
|
||||
);
|
||||
expect(await fs.promises.readFile(protectedFile, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it("prunes items after the configured retention period", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "old.txt");
|
||||
await fs.promises.writeFile(original, "old");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.deletedAt = "2020-01-01T00:00:00.000Z";
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
expect(
|
||||
fs.existsSync(path.join(home, ".termix-trash", "files", trashed.id)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
|
||||
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../../hosts/file-manager/session.js")
|
||||
>()),
|
||||
getSessionSftp: mocks.getSessionSftp,
|
||||
}));
|
||||
|
||||
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
|
||||
|
||||
type RouteHandler = (request: Request, response: Response) => unknown;
|
||||
|
||||
/**
|
||||
* Behaves like ssh2's SFTP WriteStream on current Node: it destroys itself
|
||||
* inside `_final` (autoClose), so a `finish` event is never emitted; only
|
||||
* `close` follows the last acknowledged write.
|
||||
*/
|
||||
class Ssh2LikeWriteStream extends Writable {
|
||||
chunks: Buffer[] = [];
|
||||
constructor() {
|
||||
super({ emitClose: false, autoDestroy: false });
|
||||
}
|
||||
_write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
|
||||
this.chunks.push(Buffer.from(chunk));
|
||||
cb();
|
||||
}
|
||||
_final(cb: () => void) {
|
||||
this.destroy();
|
||||
cb();
|
||||
}
|
||||
_destroy(err: Error | null, cb: (err: Error | null) => void) {
|
||||
// The handle close round-trip happens later; 'close' is asynchronous.
|
||||
setTimeout(() => {
|
||||
cb(err);
|
||||
this.emit("close");
|
||||
}, 5);
|
||||
}
|
||||
get body() {
|
||||
return Buffer.concat(this.chunks);
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const routes = new Map<string, RouteHandler>();
|
||||
const app = {
|
||||
get: vi.fn(),
|
||||
post: (path: string, handler: RouteHandler) => routes.set(path, handler),
|
||||
} as unknown as Express;
|
||||
const session = { isConnected: true, lastActive: 0 } as SSHSession;
|
||||
registerFileContentRoutes(app, {
|
||||
sshSessions: { s1: session },
|
||||
verifySessionOwnership: () => true,
|
||||
});
|
||||
const writeStream = new Ssh2LikeWriteStream();
|
||||
const unlink = vi.fn((_p: string, cb: (e: null) => void) => cb(null));
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
createWriteStream: vi.fn(() => writeStream),
|
||||
unlink,
|
||||
});
|
||||
const response = { status: vi.fn(), json: vi.fn() } as unknown as Response;
|
||||
vi.mocked(response.status).mockReturnValue(response);
|
||||
const responded = new Promise<unknown>((resolve) => {
|
||||
vi.mocked(response.json).mockImplementation((payload: unknown) => {
|
||||
resolve(payload);
|
||||
return response;
|
||||
});
|
||||
});
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("route never responded")), 2000),
|
||||
);
|
||||
return {
|
||||
routes,
|
||||
writeStream,
|
||||
unlink,
|
||||
response,
|
||||
responded: Promise.race([responded, timeout]),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => mocks.getSessionSftp.mockReset());
|
||||
|
||||
describe("uploadFileStream completion", () => {
|
||||
function multipartRequest(body: Buffer, fields: Record<string, string>) {
|
||||
const boundary = "----termixTestBoundary";
|
||||
let preamble = "";
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`;
|
||||
}
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
||||
const req = new PassThrough() as PassThrough & Request;
|
||||
Object.assign(req, {
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
userId: "u1",
|
||||
complete: false,
|
||||
});
|
||||
const send = async () => {
|
||||
req.write(preamble);
|
||||
req.write(body);
|
||||
req.write(`\r\n--${boundary}--\r\n`);
|
||||
(req as { complete: boolean }).complete = true;
|
||||
req.end();
|
||||
};
|
||||
return { req, send };
|
||||
}
|
||||
|
||||
it("answers once the SFTP stream closes, even though ssh2 never emits 'finish'", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const payload = Buffer.from("hello over sftp");
|
||||
const { req, send } = multipartRequest(payload, {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
});
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
|
||||
await send();
|
||||
const answer = (await responded) as { message: string; path: string };
|
||||
expect(answer.message).toBe("File uploaded successfully");
|
||||
expect(answer.path).toBe("/home/ubuntu/payload.bin");
|
||||
expect(writeStream.body.equals(payload)).toBe(true);
|
||||
expect(vi.mocked(response.status)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report success when the client aborts before the body ended", async () => {
|
||||
const { routes, writeStream, unlink, response } = setup();
|
||||
const { req } = multipartRequest(Buffer.alloc(0), {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
});
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
|
||||
// Headers + the beginning of the file part arrive, then the socket dies.
|
||||
req.write(
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="sessionId"\r\n\r\ns1\r\n` +
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="path"\r\n\r\n/home/ubuntu\r\n` +
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\n\r\npartial`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
req.emit("aborted");
|
||||
// The partial is removed and its stream closes; that close is not a success.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(writeStream.destroyed).toBe(true);
|
||||
expect(unlink).toHaveBeenCalledWith(
|
||||
"/home/ubuntu/payload.bin",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(vi.mocked(response.json)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFileChunk completion", () => {
|
||||
function chunkRequest(query: Record<string, string>) {
|
||||
const req = new PassThrough() as PassThrough & Request;
|
||||
Object.assign(req, { query, headers: {}, userId: "u1" });
|
||||
return req;
|
||||
}
|
||||
const query = {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
fileName: "big.bin",
|
||||
offset: "0",
|
||||
totalSize: "6",
|
||||
};
|
||||
|
||||
it("streams the raw body and answers on the ssh2 'close' event", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const req = chunkRequest(query);
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
|
||||
await new Promise((r) => setTimeout(r, 10)); // let getSessionSftp resolve
|
||||
req.write("abc");
|
||||
req.end("def");
|
||||
const answer = (await responded) as {
|
||||
bytesWritten: number;
|
||||
nextOffset: number;
|
||||
complete: boolean;
|
||||
};
|
||||
expect(answer).toMatchObject({
|
||||
bytesWritten: 6,
|
||||
nextOffset: 6,
|
||||
complete: true,
|
||||
});
|
||||
expect(writeStream.body.toString()).toBe("abcdef");
|
||||
});
|
||||
|
||||
it("still writes the chunk when middleware already drained the body into req.body", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const req = chunkRequest(query);
|
||||
// What express.raw() leaves behind: a Buffer body and an ended stream.
|
||||
Object.assign(req, { body: Buffer.from("abcdef") });
|
||||
req.end();
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
|
||||
const answer = (await responded) as { bytesWritten: number };
|
||||
expect(answer.bytesWritten).toBe(6);
|
||||
expect(writeStream.body.toString()).toBe("abcdef");
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
formatMtime,
|
||||
getMimeType,
|
||||
detectBinary,
|
||||
parseLsDateToTimestamp,
|
||||
} from "../../../hosts/file-manager/utils.js";
|
||||
|
||||
describe("isExecutableFile", () => {
|
||||
it("flags scripts with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags known executable extensions with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags extensionless files with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag files without execute permission", () => {
|
||||
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
|
||||
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag non-script data files even when executable", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modeToPermissions", () => {
|
||||
it("renders a regular file with rwxr-xr-x", () => {
|
||||
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a directory prefix", () => {
|
||||
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a symlink prefix", () => {
|
||||
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
|
||||
});
|
||||
|
||||
it("renders a read-only file", () => {
|
||||
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
|
||||
});
|
||||
|
||||
it("renders no permissions", () => {
|
||||
expect(modeToPermissions(0o100000)).toBe("----------");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMtime", () => {
|
||||
it("uses HH:MM format for recent timestamps", () => {
|
||||
// Within the last 6 months relative to now.
|
||||
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
|
||||
const result = formatMtime(recent);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("uses the year for old timestamps", () => {
|
||||
// ~2 years ago is comfortably outside the 6-month window.
|
||||
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
|
||||
const result = formatMtime(old);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMimeType", () => {
|
||||
it("maps known extensions", () => {
|
||||
expect(getMimeType("readme.txt")).toBe("text/plain");
|
||||
expect(getMimeType("data.json")).toBe("application/json");
|
||||
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
|
||||
expect(getMimeType("archive.zip")).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for unknown or missing extensions", () => {
|
||||
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
|
||||
expect(getMimeType("noextension")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLsDateToTimestamp", () => {
|
||||
it("parses a year-format date string", () => {
|
||||
const ts = parseLsDateToTimestamp("Dec 12 2025");
|
||||
const d = new Date(ts * 1000);
|
||||
expect(d.getFullYear()).toBe(2025);
|
||||
expect(d.getMonth()).toBe(11);
|
||||
expect(d.getDate()).toBe(12);
|
||||
});
|
||||
|
||||
it("parses a time-format date string for a recent file", () => {
|
||||
const now = new Date();
|
||||
const month = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
][now.getMonth()];
|
||||
const day = now.getDate();
|
||||
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
|
||||
const d = new Date(ts * 1000);
|
||||
expect(d.getHours()).toBe(10);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty or invalid string", () => {
|
||||
expect(parseLsDateToTimestamp("")).toBe(0);
|
||||
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
|
||||
});
|
||||
|
||||
it("produces ascending order for older vs newer dates", () => {
|
||||
const older = parseLsDateToTimestamp("Jan 1 2020");
|
||||
const newer = parseLsDateToTimestamp("Dec 31 2024");
|
||||
expect(older).toBeLessThan(newer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectBinary", () => {
|
||||
it("returns false for empty buffers", () => {
|
||||
expect(detectBinary(Buffer.from([]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain UTF-8 text", () => {
|
||||
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when null bytes are present", () => {
|
||||
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows common whitespace control characters", () => {
|
||||
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,11 @@ function target(id: number, extra: Partial<Target> = {}): Target {
|
||||
};
|
||||
}
|
||||
|
||||
function setup(targets: Target[], reachable = true) {
|
||||
function setup(
|
||||
targets: Target[],
|
||||
reachable = true,
|
||||
shared: Record<string, number[]> = {},
|
||||
) {
|
||||
const emitted: unknown[] = [];
|
||||
const ping = vi.fn(async () => reachable);
|
||||
const pingThroughJumpHosts = vi.fn(async () => reachable);
|
||||
@@ -45,6 +49,7 @@ function setup(targets: Target[], reachable = true) {
|
||||
);
|
||||
const service = new HostStatusService({
|
||||
loadTargets,
|
||||
loadSharedHostIds: async (userId) => shared[userId] ?? [],
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
globalInterval: () => 60,
|
||||
@@ -87,6 +92,30 @@ describe("HostStatusService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a host shared with the user before its owner ever asks", async () => {
|
||||
const { service, ping } = setup([target(5)], true, { recipient: [5] });
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("recipient", null);
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
|
||||
expect(service.get(5)?.status).toBe("reachable");
|
||||
});
|
||||
|
||||
it("only starts the shared hosts a desktop request names", async () => {
|
||||
const { service, ping } = setup([target(5), target(6)], true, {
|
||||
recipient: [5, 6],
|
||||
});
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("recipient", new Set([6]));
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
|
||||
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
|
||||
});
|
||||
|
||||
it("reports offline for a host that does not answer", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Boots core the way starter.ts does, against a real SQLite database that
|
||||
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
|
||||
* dist/plugins rather than source. Shared by the boot and upgrade tests.
|
||||
*
|
||||
* Needs `npm run build` (or build:sdk and build:plugins) first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
|
||||
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
|
||||
|
||||
/** Every plugin in the repo, by folder. */
|
||||
export function sourcePluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Throws with the fix when dist/plugins is missing a plugin. */
|
||||
export function assertPluginsBuilt(): void {
|
||||
const missing = sourcePluginIds().filter(
|
||||
(id) =>
|
||||
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function builtManifest(id: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
|
||||
export function create28Database() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
|
||||
const columns = new Map<string, Set<string>>();
|
||||
const insert = (table: string, row: Row): number => {
|
||||
let known = columns.get(table);
|
||||
if (!known) {
|
||||
known = new Set(
|
||||
(
|
||||
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((column) => column.name),
|
||||
);
|
||||
columns.set(table, known);
|
||||
}
|
||||
const entries = Object.entries(row).filter(([key]) => known!.has(key));
|
||||
const sql = `INSERT INTO "${table}" (${entries
|
||||
.map(([key]) => `"${key}"`)
|
||||
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
|
||||
const values = entries.map(([, value]) =>
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? 1
|
||||
: 0
|
||||
: value !== null && typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
|
||||
};
|
||||
return { sqlite, insert };
|
||||
}
|
||||
|
||||
export interface BootedCore {
|
||||
dataDir: string;
|
||||
/** The live database core and the plugins write to. */
|
||||
sqlite: Database.Database;
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
|
||||
* sequence up to, not including, opening the HTTP server. Call
|
||||
* vi.resetModules() before, so module state from other tests is gone.
|
||||
*/
|
||||
export async function bootCore(seed: Database.Database): Promise<BootedCore> {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
|
||||
const { SystemCrypto } = await import("../../utils/system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
const db = await import("../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
const { runCoreBootMigrations } = await import("../../boot.js");
|
||||
await runCoreBootMigrations();
|
||||
|
||||
const { primeKnownPermissions } =
|
||||
await import("../../utils/known-permissions.js");
|
||||
await primeKnownPermissions();
|
||||
|
||||
const { initializePlugins, shutdownPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
await initializePlugins();
|
||||
|
||||
const { runPluginDataMigrations } =
|
||||
await import("../../plugins/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
sqlite: db.getSqlite(),
|
||||
shutdown: async () => {
|
||||
await shutdownPlugins();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The built plugins really load: every bundle in dist/plugins activates on a
|
||||
* database that started in 2.8 shape, in dependency order, with every
|
||||
* service it needs provided and nothing registered twice.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
builtManifest,
|
||||
create28Database,
|
||||
sourcePluginIds,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
|
||||
const BOOT_TIMEOUT = 180_000;
|
||||
|
||||
let core: BootedCore;
|
||||
const logged = { errors: [] as string[], warnings: [] as string[] };
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
vi.resetModules();
|
||||
const { sqlite } = create28Database();
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
logged.errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
|
||||
logged.warnings.push(args.map(String).join(" "));
|
||||
});
|
||||
try {
|
||||
core = await bootCore(sqlite);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await core?.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("the built plugins on a 2.8 database", () => {
|
||||
it("boots without logging an error", () => {
|
||||
expect(logged.errors).toEqual([]);
|
||||
if (process.env.SHOW_BOOT_WARNINGS) {
|
||||
console.log(logged.warnings.join("\n"));
|
||||
}
|
||||
});
|
||||
|
||||
it("activates every plugin, none failed or blocked", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const states = Object.fromEntries(
|
||||
getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => [plugin.id, plugin.state]),
|
||||
);
|
||||
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
|
||||
const notActive = Object.entries(states).filter(
|
||||
([, state]) => state !== "active",
|
||||
);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every declared service a provider and meets every hard dependency", async () => {
|
||||
const { listServices } = await import("../../plugins/service-registry.js");
|
||||
const provided = new Set(
|
||||
listServices().map((service) => `${service.pluginId}:${service.service}`),
|
||||
);
|
||||
const ids = new Set(sourcePluginIds());
|
||||
|
||||
for (const id of ids) {
|
||||
const manifest = builtManifest(id) as {
|
||||
provides?: Array<{ service: string }>;
|
||||
requires?: Array<{ service: string; optional?: boolean }>;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
for (const { service } of manifest.provides ?? []) {
|
||||
expect(provided, `${id} provides ${service}`).toContain(
|
||||
`${id}:${service}`,
|
||||
);
|
||||
}
|
||||
const available = new Set(
|
||||
listServices().map((service) => service.service),
|
||||
);
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
if (requirement.optional) continue;
|
||||
expect(available, `${id} requires ${requirement.service}`).toContain(
|
||||
requirement.service,
|
||||
);
|
||||
}
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
||||
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("registers no route, socket or permission that clashes", async () => {
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toEqual([]);
|
||||
|
||||
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
|
||||
const sockets = getRegisteredWsRoutes();
|
||||
expect(new Set(sockets).size).toBe(sockets.length);
|
||||
|
||||
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
|
||||
// claiming one id, which the loader refuses; a legacy path must start
|
||||
// with its own plugin id.
|
||||
for (const id of sourcePluginIds()) {
|
||||
const legacy =
|
||||
(
|
||||
builtManifest(id) as {
|
||||
contributes?: { http?: { legacyPaths?: string[] } };
|
||||
}
|
||||
).contributes?.http?.legacyPaths ?? [];
|
||||
for (const legacyPath of legacy) {
|
||||
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const { getPermissionCatalog } =
|
||||
await import("../../utils/permission-catalog.js");
|
||||
const permissions = getPermissionCatalog().flatMap(
|
||||
(entry) => entry.permissions,
|
||||
);
|
||||
expect(new Set(permissions).size).toBe(permissions.length);
|
||||
});
|
||||
|
||||
it("records every plugin migration it ran", async () => {
|
||||
const recorded = core.sqlite
|
||||
.prepare(
|
||||
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
|
||||
)
|
||||
.all() as Array<{ plugin_id: string; n: number }>;
|
||||
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
|
||||
for (const id of sourcePluginIds()) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
const files = fs.existsSync(dir)
|
||||
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
|
||||
: [];
|
||||
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,20 @@ describe("ctx.events namespacing", () => {
|
||||
|
||||
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("gates listening to core topics the same way", () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
expect(() => ctx.events.on("user.deleted", () => {})).toThrow(
|
||||
/may only listen to/,
|
||||
);
|
||||
expect(() =>
|
||||
ctx.events.on("plugin.other-plugin.thing", () => {}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
context(["events:core"]).events.on("user.deleted", () => {}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.kv limits", () => {
|
||||
|
||||
@@ -81,3 +81,31 @@ describe("ctx.audit.record", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.audit.record with a request", () => {
|
||||
it("records the request's IP address and user agent", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({
|
||||
action: "collab_room_end",
|
||||
success: true,
|
||||
request: {
|
||||
ip: "203.0.113.4",
|
||||
headers: { "user-agent": "TestAgent/1.0" },
|
||||
},
|
||||
});
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
ipAddress: "203.0.113.4",
|
||||
userAgent: "TestAgent/1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the socket address on an upgrade request", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({
|
||||
action: "collab_join",
|
||||
success: true,
|
||||
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
|
||||
});
|
||||
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ const state = vi.hoisted(() => ({
|
||||
warnings: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
@@ -29,7 +33,9 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
warn: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
error: vi.fn(),
|
||||
error: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
@@ -265,6 +271,11 @@ describe("namespace escalation is refused", () => {
|
||||
expect(isValidPermission("docker.ps")).toBe(true);
|
||||
expect(isValidPermission("docker.automations.run")).toBe(false);
|
||||
expect(state.warnings.join()).toMatch(/belongs to someone else/);
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toContainEqual(
|
||||
expect.objectContaining({ kind: "permission", heldBy: "automations" }),
|
||||
);
|
||||
} finally {
|
||||
await loader.shutdown();
|
||||
fixture.cleanup();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FETCH_BLOCKED_PORTS,
|
||||
installSafePorts,
|
||||
} from "../../../../packages/plugin-sdk/src/testing/safe-ports.js";
|
||||
|
||||
let server: http.Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
server?.listening ? server.close(() => resolve()) : resolve(),
|
||||
);
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("installSafePorts", () => {
|
||||
it("listens again when the random port is one fetch refuses", async () => {
|
||||
installSafePorts();
|
||||
server = http.createServer((_req, res) => res.end("ok"));
|
||||
const realAddress = server.address.bind(server);
|
||||
vi.spyOn(server, "address").mockImplementationOnce(() => ({
|
||||
address: "127.0.0.1",
|
||||
family: "IPv4",
|
||||
port: 6000,
|
||||
}));
|
||||
const onListening = vi.fn();
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server!.listen(0, "127.0.0.1", () => {
|
||||
onListening();
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onListening).toHaveBeenCalledTimes(1);
|
||||
const { port } = realAddress() as AddressInfo;
|
||||
expect(FETCH_BLOCKED_PORTS.has(port)).toBe(false);
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(await response.text()).toBe("ok");
|
||||
});
|
||||
|
||||
it("leaves a fixed port alone", async () => {
|
||||
installSafePorts();
|
||||
server = http.createServer();
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
|
||||
server = http.createServer();
|
||||
await new Promise<void>((resolve) => server!.listen(port, resolve));
|
||||
expect((server.address() as AddressInfo).port).toBe(port);
|
||||
});
|
||||
});
|
||||
@@ -395,3 +395,22 @@ describe("service registry", () => {
|
||||
expect(error.message).toContain("provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider clashes", () => {
|
||||
it("refuses a second plugin answering to the same service and name", async () => {
|
||||
register();
|
||||
expect(() => register({ pluginId: "other-plugin" })).toThrow(
|
||||
/already provided by provider-plugin/,
|
||||
);
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toContainEqual(
|
||||
expect.objectContaining({ kind: "service", pluginId: "other-plugin" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a plugin replace its own registration", () => {
|
||||
register();
|
||||
expect(() => register()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -372,3 +372,83 @@ describe("settings field permissions", () => {
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host field options", () => {
|
||||
const host = (fields: unknown[], extra: Record<string, unknown> = {}) =>
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "defaultOn", type: "boolean", labelKey: "k" },
|
||||
{ key: "token", type: "secret", labelKey: "k" },
|
||||
],
|
||||
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
|
||||
});
|
||||
|
||||
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host(
|
||||
[
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "defaultOn",
|
||||
shareRead: "edit",
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
{ enableDefault: true },
|
||||
),
|
||||
),
|
||||
).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("rejects an unknown shareRead level and host options on admin fields", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{ key: "mode", type: "boolean", labelKey: "k", shareRead: "all" },
|
||||
]),
|
||||
).join(),
|
||||
).toMatch(/shareRead/);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "boolean", labelKey: "k", ownerOnly: true },
|
||||
],
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/only valid on host fields/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.legacyPaths", () => {
|
||||
it("accepts a path under the plugin's own id", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: { http: { legacyPaths: ["/sample-plugin/hook"] } },
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a path outside the plugin's id", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { legacyPaths: ["/users/login"] } } }),
|
||||
).join(),
|
||||
).toMatch(/must start with "\/sample-plugin\/"/);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,11 @@ const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
permissions: new Set<string>(),
|
||||
hostEditAccess: true,
|
||||
hostOwner: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
@@ -67,7 +72,7 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
canAccessHost: async () => ({
|
||||
hasAccess: state.hostEditAccess,
|
||||
isOwner: state.hostEditAccess,
|
||||
isOwner: state.hostEditAccess && state.hostOwner,
|
||||
isShared: false,
|
||||
}),
|
||||
}),
|
||||
@@ -115,7 +120,10 @@ const MANIFEST: PluginManifest = {
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
fields: [
|
||||
{ key: "port", type: "number", labelKey: "k" },
|
||||
{ key: "profile", type: "string", labelKey: "k", ownerOnly: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -214,6 +222,7 @@ beforeEach(async () => {
|
||||
state.userId = "user-1";
|
||||
state.permissions = new Set<string>();
|
||||
state.hostEditAccess = true;
|
||||
state.hostOwner = true;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -440,6 +449,40 @@ describe("user scope", () => {
|
||||
});
|
||||
|
||||
describe("host scope", () => {
|
||||
it("keeps an owner-only field from a shared editor", async () => {
|
||||
state.hostOwner = false;
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profile: "other", port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(400);
|
||||
expect((await write.json()).errors.profile).toMatch(/owner/);
|
||||
expect(rows).toHaveLength(0);
|
||||
|
||||
state.hostOwner = true;
|
||||
const owner = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profile: "mine" }),
|
||||
});
|
||||
expect(owner.status).toBe(200);
|
||||
});
|
||||
|
||||
it("runs the plugin's validators before writing anything", async () => {
|
||||
const { onSettingsValidate, clearSettingsListeners } =
|
||||
await import("../../plugins/settings.js");
|
||||
onSettingsValidate("sample", "host", (values) =>
|
||||
Number(values.port) > 1000 ? { port: "Too high" } : undefined,
|
||||
);
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enableThing: true, port: 5000 }),
|
||||
});
|
||||
expect(write.status).toBe(400);
|
||||
expect((await write.json()).errors).toEqual({ port: "Too high" });
|
||||
expect(rows).toHaveLength(0);
|
||||
clearSettingsListeners("sample");
|
||||
});
|
||||
|
||||
it("lets a user with edit access read and write", async () => {
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
|
||||
@@ -72,12 +72,24 @@ const settingsRepository = {
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
),
|
||||
listByKey: vi.fn(async (pluginId: string, scope: string, key: string) =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId && row.scope === scope && row.key === key,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const hostOwners = new Map<number, string>();
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => settingsRepository,
|
||||
createCurrentHostRepository: () => ({
|
||||
findById: async (id: number) =>
|
||||
hostOwners.has(id) ? { id, userId: hostOwners.get(id) } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
@@ -106,9 +118,12 @@ const {
|
||||
findField,
|
||||
getAllSettings,
|
||||
getSetting,
|
||||
listHostValues,
|
||||
onSettingsChange,
|
||||
onSettingsValidate,
|
||||
readCoreSetting,
|
||||
setSetting,
|
||||
validateSettingsSave,
|
||||
} = await import("../../plugins/settings.js");
|
||||
|
||||
function manifest(settings: unknown): PluginManifest {
|
||||
@@ -440,3 +455,86 @@ describe("readCoreSetting", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings validators", () => {
|
||||
it("collects every validator's errors for its scope only", async () => {
|
||||
const stop = onSettingsValidate("validated", "admin", (values) =>
|
||||
values.url === "bad" ? { url: "Nope" } : undefined,
|
||||
);
|
||||
onSettingsValidate("validated", "user", () => ({ other: "Wrong scope" }));
|
||||
|
||||
expect(
|
||||
await validateSettingsSave("validated", "admin", null, { url: "bad" }),
|
||||
).toEqual({ url: "Nope" });
|
||||
expect(
|
||||
await validateSettingsSave("validated", "admin", null, { url: "ok" }),
|
||||
).toEqual({});
|
||||
|
||||
stop();
|
||||
clearSettingsListeners("validated");
|
||||
expect(await validateSettingsSave("validated", "user", null, {})).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the host id to a host validator", async () => {
|
||||
let seen: number | undefined;
|
||||
onSettingsValidate("validated-host", "host", (_values, context) => {
|
||||
seen = context.hostId;
|
||||
});
|
||||
await validateSettingsSave("validated-host", "host", "12", {});
|
||||
expect(seen).toBe(12);
|
||||
clearSettingsListeners("validated-host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listHostValues", () => {
|
||||
const withHostField = manifest({
|
||||
host: {
|
||||
enableKey: "on",
|
||||
enableLabelKey: "x",
|
||||
fields: [{ key: "token", type: "secret", labelKey: "x" }],
|
||||
},
|
||||
});
|
||||
|
||||
it("lists stored values with each host's owner", async () => {
|
||||
hostOwners.set(7, "alice");
|
||||
hostOwners.set(8, "bob");
|
||||
await setSetting(withHostField, "host", 7, "on", true);
|
||||
await setSetting(withHostField, "host", 8, "on", false);
|
||||
await setSetting(withHostField, "host", 99, "on", true);
|
||||
|
||||
const listed = await listHostValues(withHostField, "on");
|
||||
expect(listed).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ hostId: 7, userId: "alice", value: true },
|
||||
{ hostId: 8, userId: "bob", value: false },
|
||||
]),
|
||||
);
|
||||
// A row for a host that no longer exists is skipped.
|
||||
expect(listed.find((entry) => entry.hostId === 99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a secret field", async () => {
|
||||
await expect(listHostValues(withHostField, "token")).rejects.toThrow(
|
||||
/secret/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableDefault", () => {
|
||||
it("is what the host switch reads before a host saves it", () => {
|
||||
const fields = declaredFields(
|
||||
manifest({
|
||||
host: {
|
||||
enableKey: "on",
|
||||
enableLabelKey: "x",
|
||||
enableDefault: true,
|
||||
fields: [],
|
||||
},
|
||||
}),
|
||||
"host",
|
||||
);
|
||||
expect(fields[0]).toMatchObject({ key: "on", default: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The CLI's ?url import: the file lands under dist/assets/ and the import
|
||||
* becomes a URL relative to the bundle, which /plugin-assets/<id>/ serves.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
// @ts-expect-error the CLI is plain ESM without types
|
||||
import { staticUrlImports } from "../../../../packages/plugin-sdk/cli/lib/static-url-imports.mjs";
|
||||
|
||||
const dirs: string[] = [];
|
||||
function tempDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-url-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function bundle(entrySource: string, files: Record<string, string>) {
|
||||
const dir = tempDir();
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), contents);
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, "entry.js"), entrySource);
|
||||
const outDir = path.join(dir, "dist");
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [path.join(dir, "entry.js")],
|
||||
outfile: path.join(outDir, "frontend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
plugins: [staticUrlImports({ outDir })],
|
||||
});
|
||||
return { outDir, result };
|
||||
}
|
||||
|
||||
describe("staticUrlImports", () => {
|
||||
it("copies a worker next to the bundle and imports its URL", async () => {
|
||||
const { outDir } = await bundle(
|
||||
`import url from "./worker.mjs?url"; export default url;`,
|
||||
{ "worker.mjs": "self.onmessage = () => {};" },
|
||||
);
|
||||
|
||||
const assets = fs.readdirSync(path.join(outDir, "assets"));
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatch(/^worker-[0-9a-f]{8}\.js$/);
|
||||
const code = fs.readFileSync(path.join(outDir, "frontend.js"), "utf8");
|
||||
expect(code).toContain(`new URL("./assets/${assets[0]}", import.meta.url)`);
|
||||
});
|
||||
|
||||
it("refuses a file the asset route would not serve", async () => {
|
||||
await expect(
|
||||
bundle(`import url from "./image.png?url"; export default url;`, {
|
||||
"image.png": "not really",
|
||||
}),
|
||||
).rejects.toThrow(/only script files/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* The quick upgrade check: a 2.8 database with rows in the tables plugins
|
||||
* adopt, host columns plugins took over and settings keys they moved boots
|
||||
* into 2.9.0, and the data reads back through the plugins' own APIs.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
create28Database,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
|
||||
const BOOT_TIMEOUT = 180_000;
|
||||
const USER = "user-upgrade";
|
||||
|
||||
let core: BootedCore;
|
||||
let app: express.Express;
|
||||
let token: string;
|
||||
const ids: Record<string, number> = {};
|
||||
|
||||
function seed() {
|
||||
const { sqlite, insert } = create28Database();
|
||||
insert("users", {
|
||||
id: USER,
|
||||
username: "admin",
|
||||
password_hash: "not-a-real-hash",
|
||||
is_admin: 1,
|
||||
});
|
||||
|
||||
// A host that changed every plugin-owned switch away from its default...
|
||||
ids.custom = insert("ssh_data", {
|
||||
user_id: USER,
|
||||
name: "custom",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
auth_type: "password",
|
||||
enable_terminal: 1,
|
||||
enable_command_history: 0,
|
||||
enable_terminal_toolbar: 0,
|
||||
enable_session_logging: 0,
|
||||
allow_session_sharing: 0,
|
||||
enable_file_manager: 0,
|
||||
default_path: "/srv/data",
|
||||
scp_legacy: 1,
|
||||
enable_tunnel: 1,
|
||||
tunnel_connections: [
|
||||
{
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
endpointHost: "custom",
|
||||
autoStart: false,
|
||||
},
|
||||
],
|
||||
enable_web_ui: 1,
|
||||
web_ui_config: {
|
||||
endpoints: [
|
||||
{ id: "grafana", label: "Grafana", port: 3000, protocol: "http" },
|
||||
],
|
||||
},
|
||||
enable_tmux_monitor: 1,
|
||||
enable_docker: 1,
|
||||
});
|
||||
// ...and one that never touched them, which must keep 2.8's defaults.
|
||||
ids.plain = insert("ssh_data", {
|
||||
user_id: USER,
|
||||
name: "plain",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
auth_type: "password",
|
||||
});
|
||||
|
||||
insert("snippets", {
|
||||
user_id: USER,
|
||||
name: "upgrade-snippet",
|
||||
content: "uptime",
|
||||
});
|
||||
insert("fleets", { user_id: USER, name: "upgrade-fleet" });
|
||||
insert("user_workspaces", {
|
||||
user_id: USER,
|
||||
name: "upgrade-workspace",
|
||||
payload: {},
|
||||
});
|
||||
insert("network_topology", {
|
||||
user_id: USER,
|
||||
topology: { nodes: [{ id: "upgrade-node" }], edges: [] },
|
||||
});
|
||||
insert("automations", {
|
||||
user_id: USER,
|
||||
name: "upgrade-automation",
|
||||
enabled: 0,
|
||||
definition: {
|
||||
version: 1,
|
||||
trigger: { type: "manual" },
|
||||
steps: [],
|
||||
},
|
||||
});
|
||||
insert("homepage_items", {
|
||||
user_id: USER,
|
||||
type_id: "clock",
|
||||
title: "upgrade-widget",
|
||||
config: {},
|
||||
});
|
||||
insert("vault_profiles", {
|
||||
user_id: USER,
|
||||
name: "upgrade-vault",
|
||||
vault_addr: "https://vault.example.com",
|
||||
ssh_role: "admin",
|
||||
});
|
||||
insert("termix_identities", { user_id: USER, handle: "upgrade-handle" });
|
||||
insert("sso_providers", {
|
||||
name: "upgrade-sso",
|
||||
type: "oidc",
|
||||
config: {
|
||||
issuerUrl: "https://id.example.com",
|
||||
clientId: "termix",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
});
|
||||
|
||||
insert("settings", { key: "terminal_session_timeout_minutes", value: "45" });
|
||||
insert("settings", { key: "step_ca_url", value: "https://ca.example.com" });
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
vi.resetModules();
|
||||
core = await bootCore(seed());
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const auth = AuthManager.getInstance();
|
||||
await auth.registerUser(USER);
|
||||
await auth.unlockWithSystemKey(USER);
|
||||
token = await auth.generateJWTToken(USER, {
|
||||
deviceType: "web",
|
||||
deviceInfo: "upgrade test",
|
||||
});
|
||||
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
const { default: pluginRoutes } =
|
||||
await import("../../database/routes/plugins.js");
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", pluginRoutes);
|
||||
mountPluginApi(app);
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await core?.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
function get(path: string) {
|
||||
return request(app).get(path).set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
async function hostSettings(pluginId: string, hostId: number) {
|
||||
const response = await get(`/plugins/${pluginId}/settings/host/${hostId}`);
|
||||
expect(response.status, `${pluginId} host settings`).toBe(200);
|
||||
return response.body.values as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("upgrading a 2.8 database", () => {
|
||||
it("renames every adopted table the 2.8 schema had", () => {
|
||||
const tables = new Set(
|
||||
(
|
||||
core.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all() as Array<{ name: string }>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
for (const legacy of [
|
||||
"snippets",
|
||||
"fleets",
|
||||
"user_workspaces",
|
||||
"network_topology",
|
||||
"automations",
|
||||
"homepage_items",
|
||||
"vault_profiles",
|
||||
"termix_identities",
|
||||
"sso_providers",
|
||||
]) {
|
||||
expect(tables.has(legacy), `${legacy} was adopted`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the adopted rows back through each plugin's routes", async () => {
|
||||
const checks: Array<[string, string]> = [
|
||||
["/plugin-api/snippets/", "upgrade-snippet"],
|
||||
["/plugin-api/fleets/", "upgrade-fleet"],
|
||||
["/plugin-api/workspaces/", "upgrade-workspace"],
|
||||
["/plugin-api/network-topology/", "upgrade-node"],
|
||||
["/plugin-api/automations/", "upgrade-automation"],
|
||||
["/plugin-api/homepage/items", "upgrade-widget"],
|
||||
["/plugin-api/vault/profiles", "upgrade-vault"],
|
||||
["/plugin-api/termix-identity/me", "upgrade-handle"],
|
||||
["/plugin-api/sso/providers", "upgrade-sso"],
|
||||
];
|
||||
for (const [path, marker] of checks) {
|
||||
const response = await get(path);
|
||||
expect(response.status, path).toBe(200);
|
||||
expect(JSON.stringify(response.body), path).toContain(marker);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves every changed host switch into its plugin's host settings", async () => {
|
||||
expect(await hostSettings("ssh-terminal", ids.custom)).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
});
|
||||
expect(await hostSettings("session-recording", ids.custom)).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
expect(await hostSettings("session-sharing", ids.custom)).toMatchObject({
|
||||
allowSessionSharing: false,
|
||||
});
|
||||
expect(await hostSettings("file-manager", ids.custom)).toMatchObject({
|
||||
enableFileManager: false,
|
||||
defaultPath: "/srv/data",
|
||||
scpLegacy: true,
|
||||
});
|
||||
const tunnels = await hostSettings("tunnels", ids.custom);
|
||||
expect(tunnels.enableTunnel).toBe(true);
|
||||
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
|
||||
const web = await hostSettings("web-endpoint", ids.custom);
|
||||
expect(web.enableWebUi).toBe(true);
|
||||
expect(JSON.stringify(web.webUiConfig)).toContain("Grafana");
|
||||
expect(await hostSettings("tmux-monitor", ids.custom)).toMatchObject({
|
||||
enableTmuxMonitor: true,
|
||||
});
|
||||
expect(await hostSettings("docker", ids.custom)).toMatchObject({
|
||||
enableDocker: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps 2.8's defaults for a host that never changed them", async () => {
|
||||
expect(await hostSettings("file-manager", ids.plain)).toMatchObject({
|
||||
enableFileManager: true,
|
||||
});
|
||||
expect(await hostSettings("ssh-terminal", ids.plain)).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
});
|
||||
expect(await hostSettings("session-recording", ids.plain)).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
});
|
||||
expect(await hostSettings("session-sharing", ids.plain)).toMatchObject({
|
||||
allowSessionSharing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves core settings keys into plugin settings", async () => {
|
||||
const terminal = await get("/plugins/ssh-terminal/settings/admin");
|
||||
expect(terminal.status).toBe(200);
|
||||
expect(terminal.body.values.sessionTimeoutMinutes).toBe(45);
|
||||
|
||||
const stepCa = await get("/plugins/step-ca/settings/admin");
|
||||
expect(stepCa.status).toBe(200);
|
||||
expect(stepCa.body.values.caUrl).toBe("https://ca.example.com");
|
||||
});
|
||||
});
|
||||
@@ -340,3 +340,12 @@ describe("parsePluginWsUrl", () => {
|
||||
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws clashes", () => {
|
||||
it("refuses the same path twice", () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED);
|
||||
expect(() =>
|
||||
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED),
|
||||
).toThrow(/already registered/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Custom certificate paths set in the environment survive a boot: they are
|
||||
* neither replaced with a self-signed certificate nor reset in DATA_DIR/.env.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock("child_process", () => ({ execSync }));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { systemLogger: log };
|
||||
});
|
||||
|
||||
const saved = { ...process.env };
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
execSync.mockReset();
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-ssl-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.ENABLE_SSL = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("AutoSSLSetup with custom paths", () => {
|
||||
it("keeps the operator's certificate and paths", async () => {
|
||||
process.env.SSL_CERT_PATH = path.join(dataDir, "mine", "cert.pem");
|
||||
process.env.SSL_KEY_PATH = path.join(dataDir, "mine", "key.pem");
|
||||
process.env.SSL_DOMAIN = "termix.example.com";
|
||||
|
||||
const { AutoSSLSetup } = await import("../../tls/self-signed.js");
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
const env = fs.readFileSync(path.join(dataDir, ".env"), "utf8");
|
||||
expect(env).toContain(`SSL_CERT_PATH=${process.env.SSL_CERT_PATH}`);
|
||||
expect(env).toContain("SSL_DOMAIN=termix.example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* An admin-allowlisted private host goes through the configured proxy; a
|
||||
* public host never does, since the proxy would skip the address check.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
calls: [] as Array<{ url: string; dispatcher: unknown }>,
|
||||
proxy: { kind: "proxy" } as unknown,
|
||||
proxyConfigured: true,
|
||||
}));
|
||||
|
||||
vi.mock("undici", () => ({
|
||||
Agent: class {
|
||||
kind = "direct";
|
||||
async close() {}
|
||||
},
|
||||
fetch: async (url: string, init: { dispatcher: unknown }) => {
|
||||
h.calls.push({ url, dispatcher: init.dispatcher });
|
||||
return new Response("ok");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/proxy-agent.js", () => ({
|
||||
getProxyAgent: () => (h.proxyConfigured ? h.proxy : undefined),
|
||||
}));
|
||||
|
||||
const { safeOutboundFetch } =
|
||||
await import("../../utils/safe-outbound-fetch.js");
|
||||
|
||||
beforeEach(() => {
|
||||
h.calls.length = 0;
|
||||
h.proxyConfigured = true;
|
||||
});
|
||||
|
||||
describe("safeOutboundFetch and the proxy", () => {
|
||||
it("sends an allowlisted private host through the proxy", async () => {
|
||||
await safeOutboundFetch("http://10.0.0.5:11434/api", {}, ["10.0.0.5"]);
|
||||
expect(h.calls[0].dispatcher).toBe(h.proxy);
|
||||
});
|
||||
|
||||
it("keeps a public host on the checked direct connection", async () => {
|
||||
await safeOutboundFetch("https://api.example.com/v1", {}, []);
|
||||
expect(h.calls[0].dispatcher).not.toBe(h.proxy);
|
||||
});
|
||||
|
||||
it("connects directly when no proxy is configured", async () => {
|
||||
h.proxyConfigured = false;
|
||||
await safeOutboundFetch("http://10.0.0.5/api", {}, ["10.0.0.5"]);
|
||||
expect(h.calls[0].dispatcher).not.toBe(h.proxy);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_session_logging: boolean | null;
|
||||
enable_session_logging: boolean | number | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
@@ -112,6 +112,16 @@ describe("runSessionRecordingSettingsMigration", () => {
|
||||
expect(storedValue(3, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads SQLite 0 and 1 as off and on", async () => {
|
||||
hostRows.push({ id: 6, enable_session_logging: 0 });
|
||||
hostRows.push({ id: 7, enable_session_logging: 1 });
|
||||
|
||||
await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(storedValue(6, "enableSessionRecording")).toBe(false);
|
||||
expect(storedValue(7, "enableSessionRecording")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a null legacy value as on, matching the old column default", async () => {
|
||||
hostRows.push({ id: 4, enable_session_logging: null });
|
||||
|
||||
|
||||
@@ -167,6 +167,18 @@ describe("runTotpMigration", () => {
|
||||
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
|
||||
});
|
||||
|
||||
it("moves only the named user when a login opens their key", async () => {
|
||||
setup({ pluginTable: true });
|
||||
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
|
||||
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
|
||||
|
||||
expect(await runTotpMigration("alice")).toEqual({ factors: 1, secrets: 1 });
|
||||
const moved = sqlite
|
||||
.prepare("SELECT user_id FROM p_totp_enrollments")
|
||||
.all();
|
||||
expect(moved).toEqual([{ user_id: "alice" }]);
|
||||
});
|
||||
|
||||
it("drops a duplicate core row when the plugin row already exists", async () => {
|
||||
setup({ pluginTable: true });
|
||||
sqlite.exec(`
|
||||
|
||||
Reference in new issue
Block a user