feat: move desktop, status, import, audit and branding features into plugins

This commit is contained in:
LukeGus committed 2026-10-18 21:17:54 -05:00
1 parent 9d93fb0893
commit 1cdbfc674c
166 files changed
+21298 -12041

No files matched your search

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
const access = vi.hoisted(() => ({ owned: true, shared: [] as number[] }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
isHostOwnedByUser: async () => access.owned,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
access.shared.map((hostId) => ({ hostId })),
}),
}));
import {
provideActivity,
recentActivity,
type ActivityProvider,
} from "../../plugins/activity-provider.js";
function fake(): ActivityProvider {
return {
record: vi.fn(async () => ({ status: "logged" as const, id: 1 })),
list: vi.fn(async () => []),
deleteByUserId: vi.fn(async () => {}),
deleteByHostIds: vi.fn(async () => {}),
};
}
const entry = { type: "ssh", hostId: 1, hostName: "h" };
describe("recent activity provider", () => {
it("is unavailable with no provider", async () => {
expect(await recentActivity.record("u", entry)).toEqual({
status: "unavailable",
});
expect(await recentActivity.list("u", 5)).toEqual([]);
await expect(recentActivity.deleteByUserId("u")).resolves.toBeUndefined();
await expect(recentActivity.deleteByHostId(7)).resolves.toBeUndefined();
});
it("routes to the plugin and back to nothing when it goes", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
await recentActivity.record("u", entry);
await recentActivity.deleteByHostId(7);
expect(plugin.record).toHaveBeenCalledOnce();
expect(plugin.deleteByHostIds).toHaveBeenCalledWith([7]);
remove();
expect(await recentActivity.list("u", 5)).toEqual([]);
expect(plugin.list).not.toHaveBeenCalled();
});
it("checks host access before the provider records", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
access.owned = false;
access.shared = [];
expect(await recentActivity.record("u", entry)).toEqual({
status: "denied",
});
expect(plugin.record).not.toHaveBeenCalled();
access.shared = [1];
expect((await recentActivity.record("u", entry)).status).toBe("logged");
remove();
access.owned = true;
});
it("refuses a second provider", () => {
const remove = provideActivity("one", fake());
expect(() => provideActivity("two", fake())).toThrow(
/already provided by plugin "one"/,
);
remove();
});
});
+118 -71
View File
@@ -1,112 +1,159 @@
/**
* ctx.audit.record: a plugin writes an audit line under its own action name,
* and the user it names is always the runtime's actor, never one the plugin
* picked.
* ctx.audit.subscribe, list and exportBatches: audit:read gates reading, and
* subscribers see lines after they are accepted without delaying the writer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
const grants = new Map<string, string[]>();
const rows = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
createCurrentAuditLogRepository: () => ({
listPage: async ({ limit }: { limit: number }) => ({
logs: rows.list.slice(0, limit),
total: rows.list.length,
}),
listForExport: async ({
limit,
offset,
}: {
limit: number;
offset: number;
}) => rows.list.slice(offset, offset + limit),
create: async () => {},
}),
createCurrentUserRepository: () => ({ findById: async () => null }),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { runAsActor } from "../../plugins/actor.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { emitAuditEntry } from "../../utils/audit-hooks.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string) {
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: "Audit Fixture",
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
return { ctx: createPluginContext(manifest, handle), handle };
}
const line = (id: number) => ({
id,
timestamp: new Date(1_700_000_000_000 + id),
userId: null,
username: "root",
action: "login",
resourceType: "user",
resourceId: null,
resourceName: null,
details: null,
ipAddress: null,
userAgent: null,
success: true,
errorMessage: null,
});
beforeEach(() => {
auditEntries.length = 0;
grants.clear();
invalidatePluginPermissionCache();
rows.list = [line(1), line(2), line(3)];
});
describe("ctx.audit.record", () => {
it("writes the plugin's action for the acting user", async () => {
const ctx = contextFor("audit-fixture");
await runAsActor("user-3", "request", () =>
ctx.audit.record({
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
success: true,
}),
describe("ctx.audit reading", () => {
it("refuses list and export without audit:read", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.audit.list()).rejects.toThrow(/audit:read/);
await expect(ctx.audit.exportBatches().next()).rejects.toThrow(
/audit:read/,
);
expect(auditEntries).toEqual([
expect.objectContaining({
userId: "user-3",
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
details: "via plugin audit-fixture",
success: true,
}),
]);
expect(() => ctx.audit.subscribe(() => {})).toThrow(/audit:read/);
});
it("falls back to the plugin as the resource and system as the user", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
it("lists stored lines", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const page = await ctx.audit.list({ limit: 2 });
expect(page.total).toBe(3);
expect(page.entries.map((entry) => entry.id)).toEqual([1, 2]);
expect(page.entries[0].timestamp).toMatch(/^20\d\d-/);
});
it("exports in batches", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const sizes: number[] = [];
for await (const batch of ctx.audit.exportBatches({}, 2)) {
sizes.push(batch.length);
}
expect(sizes).toEqual([2, 1]);
});
it("delivers new lines to a subscriber until it unsubscribes", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
const stop = ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
emitAuditEntry({
userId: null,
username: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
success: false,
});
});
});
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",
username: "root",
action: "one",
resourceType: "x",
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",
await new Promise((resolve) => setTimeout(resolve, 5));
stop();
emitAuditEntry({
userId: null,
username: "root",
action: "two",
resourceType: "x",
success: true,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual(["one"]);
});
it("falls back to the socket address on an upgrade request", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_join",
it("stops delivering when the plugin deactivates", async () => {
grants.set("demo", ["audit:read"]);
const { ctx, handle } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
await handle.bag.disposeAll();
emitAuditEntry({
userId: null,
username: "root",
action: "late",
resourceType: "x",
success: true,
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
});
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual([]);
});
});
@@ -0,0 +1,179 @@
/**
* ctx.credentials.publicKey and resolveKeyRefs, and the capability gate on
* ctx.ssh.exec.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["credentials.view"]),
credential: null as Record<string, unknown> | null,
resolve: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => state.credential,
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/stored-ssh-key.js", () => ({
resolveKeyReferences: state.resolve,
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const pair = ssh2.utils.generateKeyPairSync("ed25519");
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["credentials.view"]);
state.credential = null;
state.resolve.mockReset();
});
describe("ctx.credentials.publicKey", () => {
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials:use/,
);
});
it("refuses when the user lacks credentials.view", async () => {
grants.set("demo", ["credentials:use"]);
state.permissions = new Set();
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials.view/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("returns null for a missing credential or one that is not a key", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
state.credential = { id: 1, authType: "password" };
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
});
it("returns the stored public key", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = {
id: 1,
authType: "key",
publicKey: " ssh-ed25519 AAA ",
};
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBe(
"ssh-ed25519 AAA",
);
});
it("derives it from the private key behind a reference", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = { id: 1, authType: "key", key: "op://vault/key" };
state.resolve.mockResolvedValue({ key: pair.private });
const ctx = contextFor(["credentials:use"]);
const result = await asUser(() => ctx.credentials.publicKey(1));
expect(result).toMatch(/^ssh-ed25519 /);
expect(state.resolve).toHaveBeenCalledWith(
"user-1",
"op://vault/key",
null,
);
});
it("rejects a bad id", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(0))).rejects.toThrow(
/credential id/,
);
});
});
describe("ctx.credentials.resolveKeyRefs", () => {
it("refuses with only credentials:use", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(
asUser(() => ctx.credentials.resolveKeyRefs("op://a")),
).rejects.toThrow(/credentials:read/);
expect(state.resolve).not.toHaveBeenCalled();
});
it("resolves for the acting user and audits", async () => {
grants.set("demo", ["credentials:read"]);
state.resolve.mockResolvedValue({ key: "KEY", passphrase: "PW" });
const ctx = contextFor(["credentials:read"]);
const result = await asUser(() =>
ctx.credentials.resolveKeyRefs("op://a", "op://b"),
);
expect(result).toEqual({ key: "KEY", passphrase: "PW" });
expect(state.resolve).toHaveBeenCalledWith("user-1", "op://a", "op://b");
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_resolve_key",
success: true,
});
});
});
describe("ctx.ssh.exec", () => {
it("refuses without ssh:connect and credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.ssh.exec(1, "id"))).rejects.toThrow(
/ssh:connect/,
);
});
});
@@ -0,0 +1,248 @@
/**
* ctx.desktop.local: desktop:local is checked and audited, and nothing
* touches the machine unless the backend runs embedded in the desktop app.
*/
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 grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const bridge = vi.hoisted(() => ({
requestFromElectronMain: vi.fn(async () => ({ success: true })),
}));
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true",
requestFromElectronMain: bridge.requestFromElectronMain,
}));
vi.mock("../../sync/client/link-store.js", () => ({
getLink: async () => ({
serverUrl: "https://termix.example.test",
sessionToken: "token",
}),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
}
let dir: string;
const previous = process.env.ELECTRON_EMBEDDED;
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
bridge.requestFromElectronMain.mockClear();
process.env.ELECTRON_EMBEDDED = "true";
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
if (previous === undefined) delete process.env.ELECTRON_EMBEDDED;
else process.env.ELECTRON_EMBEDDED = previous;
});
describe("ctx.desktop.local", () => {
it("refuses without desktop:local and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("refuses when granted but not declared", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
});
it("refuses outside the desktop app even with the capability", async () => {
grants.set("demo", ["desktop:local"]);
process.env.ELECTRON_EMBEDDED = "false";
const ctx = contextFor("demo", ["desktop:local"]);
expect(ctx.desktop.local.available()).toBe(false);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/);
});
it("lists and audits once per action", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a.txt"), "hi");
const ctx = contextFor("demo", ["desktop:local"]);
const first = await ctx.desktop.local.fs.list(dir);
await ctx.desktop.local.fs.list(dir);
expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_fs",
),
).toHaveLength(1);
});
it("rejects relative paths and unsafe names", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow(
/absolute/,
);
await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow(
/Invalid/,
);
});
it("never renames onto an existing name", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a"), "1");
fs.writeFileSync(path.join(dir, "b"), "2");
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"),
).rejects.toThrow(/already exists/);
expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2");
});
it("keeps downloads inside the chosen folder and publishes on success", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "..", "escape.txt"),
transferId: "t1",
}),
).rejects.toThrow(/outside/);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "ok.txt"),
transferId: "t2",
});
download.stream.write("data");
expect(await download.publish()).toBe(path.join(dir, "ok.txt"));
expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data");
expect(fs.readdirSync(dir)).toEqual(["ok.txt"]);
});
it("removes the partial file when a download is aborted", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "gone.txt"),
transferId: "t3",
});
download.stream.write("partial");
await download.abort();
expect(fs.readdirSync(dir)).toEqual([]);
});
it("sends trash and open through Electron main and audits each call", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const target = path.join(dir, "x.txt");
fs.writeFileSync(target, "x");
await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({
trashed: 1,
failed: [],
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"shell-trash-item",
{ path: target },
);
await ctx.desktop.local.fs.open(target);
await ctx.desktop.local.fs.open(target);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_open",
),
).toHaveLength(2);
});
it("only calls the plugin's own routes", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
for (const bad of [
"relative",
"//evil.example/x",
"/../other",
"/a/../../b",
String.raw`/a\b`,
]) {
await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow(
/Invalid plugin route path/,
);
}
await expect(
ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"),
).rejects.toThrow(/Unknown origin|No acting user/);
});
it("reports the link and the embedded backend port", async () => {
grants.set("demo", ["desktop:local"]);
process.env.EMBEDDED_BACKEND_PORT = "31234";
const ctx = contextFor("demo", ["desktop:local"]);
const info = await ctx.desktop.local.info();
expect(info.backendPort).toBe(31234);
expect(info.link).toMatchObject({
serverUrl: "https://termix.example.test",
token: "token",
allowInvalidCertificate: false,
});
expect(info.link?.headers.Cookie).toBe("jwt=token");
delete process.env.EMBEDDED_BACKEND_PORT;
});
});
@@ -0,0 +1,264 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany, registerImportNormalizer:
* capability checks, core permission checks, audit lines and the registry key.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["hosts.create", "hosts.edit"]),
}));
const importer = vi.hoisted(() => ({
runBulkImport: vi.fn(),
runSshConfigImport: vi.fn(),
exportAllHosts: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/import/host-import.js", () => ({
runBulkImport: importer.runBulkImport,
runSshConfigImport: importer.runSshConfigImport,
}));
vi.mock("../../hosts/import/host-export.js", () => ({
exportAllHosts: importer.exportAllHosts,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { batched: (fn: () => unknown) => fn },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import * as registry from "../../plugins/registry.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const outcome = {
success: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [5],
};
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["hosts.create", "hosts.edit"]);
importer.runBulkImport.mockReset().mockResolvedValue(outcome);
importer.runSshConfigImport.mockReset().mockResolvedValue(outcome);
importer.exportAllHosts.mockReset().mockResolvedValue({ hosts: [{ id: 1 }] });
});
describe("ctx.hosts.importMany", () => {
it("refuses without hosts:write", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts:write/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
});
it("needs credentials:write only when credentials are passed", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).resolves.toMatchObject({ success: 1 });
await expect(
asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { credentials: [{ alias: "k" }] }),
),
).rejects.toThrow(/credentials:write/);
});
it("refuses when the acting user lacks the core permissions", async () => {
grants.set("demo", ["hosts:write"]);
state.permissions = new Set(["hosts.create"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts.edit/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: false,
});
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(ctx.hosts.importMany([{ ip: "a" }])).rejects.toThrow(
/acting user/,
);
});
it("passes the options through and audits the counts", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
const result = await asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { atomic: true }),
);
expect(result.createdHostIds).toEqual([5]);
expect(importer.runBulkImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ atomic: true, hosts: [{ ip: "a" }] }),
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: true,
});
});
it("surfaces the importer's request errors and audits the failure", async () => {
grants.set("demo", ["hosts:write"]);
importer.runBulkImport.mockRejectedValue(new Error("Maximum 100 hosts"));
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.importMany([]))).rejects.toThrow(
/Maximum 100/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
});
describe("ctx.hosts.importSshConfig", () => {
it("refuses without hosts:write and runs with it", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importSshConfig("Host a")),
).rejects.toThrow(/hosts:write/);
grants.set("demo", ["hosts:write"]);
invalidatePluginPermissionCache();
const { ctx: allowed } = contextFor("demo", ["hosts:write"]);
await asUser(() => allowed.hosts.importSshConfig("Host a"));
expect(importer.runSshConfigImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ content: "Host a" }),
);
});
});
describe("ctx.hosts.exportMany", () => {
it("refuses with only hosts:write and audits the refusal", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.exportMany())).rejects.toThrow(
/hosts:export/,
);
expect(importer.exportAllHosts).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("exports with secrets by default and audits it", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await expect(asUser(() => ctx.hosts.exportMany())).resolves.toEqual({
hosts: [{ id: 1 }],
});
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
undefined,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_export_hosts",
success: true,
});
expect(String(auditEntries.at(-1)?.details)).toMatch(/with secrets/);
});
it("passes share mode through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ share: true }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
true,
undefined,
);
expect(String(auditEntries.at(-1)?.details)).toMatch(/without secrets/);
});
it("passes the host ids filter through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ hostIds: [4, 5] }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
[4, 5],
);
});
});
describe("ctx.hosts.registerImportNormalizer", () => {
it("needs hosts:write", () => {
const { ctx } = contextFor("demo", []);
expect(() => ctx.hosts.registerImportNormalizer(() => null)).toThrow(
/hosts:write/,
);
});
it("registers under the key the import path reads and removes it on deactivate", async () => {
const { ctx, handle } = contextFor("demo", ["hosts:write"]);
const normalizer = () => ({ a: 1 });
ctx.hosts.registerImportNormalizer(normalizer);
expect(registry.consume("demo.hostImportNormalizer")).toBe(normalizer);
await handle.bag.disposeAll();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
it("returns a remover", () => {
const { ctx } = contextFor("demo", ["hosts:write"]);
const remove = ctx.hosts.registerImportNormalizer(() => null);
remove();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
});
+10 -9
View File
@@ -110,14 +110,14 @@ vi.mock("../../database/repositories/factory.js", () => ({
listDecryptedByUserId: async () => h.ownedHosts,
}),
}));
vi.mock("../../services/recent-activity.js", () => ({
recordRecentActivity: async (
userId: string,
entry: Record<string, unknown>,
) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
vi.mock("../../plugins/activity-provider.js", () => ({
recentActivity: {
record: async (userId: string, entry: Record<string, unknown>) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
},
},
provideActivity: vi.fn(() => () => {}),
}));
vi.mock("../../hosts/host-session-status.js", () => ({
hostSessionStatus: {
@@ -127,8 +127,9 @@ vi.mock("../../hosts/host-session-status.js", () => ({
},
},
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
provideHostStatus: vi.fn(() => () => {}),
hostStatus: {
get: (hostId: number) =>
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
+182
View File
@@ -0,0 +1,182 @@
/**
* ctx.network.tcpProbe, ctx.ssh.probe and ctx.hosts.probeTargets: gated by
* capability, and only ever offered hosts the actor may connect to.
*/
import net from "node:net";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const state = vi.hoisted(() => ({
owned: [] as Array<Record<string, unknown>>,
shared: [] as Array<Record<string, unknown>>,
sharedIds: [] as number[],
allowed: new Set<number>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
listStatusTargets: async (filter: {
userId?: string;
hostIds?: number[];
}) => {
const rows = filter.userId ? state.owned : state.shared;
return rows.filter(
(row) => !filter.hostIds || filter.hostIds.includes(row.id as number),
);
},
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
state.sharedIds.map((hostId) => ({ hostId })),
}),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
new Set(ids.filter((id) => state.allowed.has(id))),
canAccessHost: async (_user: string, id: number) => ({
hasAccess: state.allowed.has(id),
}),
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const row = (id: number, userId: string) => ({
id,
userId,
ip: "127.0.0.1",
port: 22,
connectionType: "ssh",
jumpHosts: null,
statusCheckEnabled: true,
statusCheckInterval: null,
});
let server: net.Server;
let port = 0;
beforeEach(async () => {
grants.clear();
invalidatePluginPermissionCache();
state.owned = [row(1, "alice")];
state.shared = [row(2, "bob"), row(3, "bob")];
state.sharedIds = [2, 3];
state.allowed = new Set([2]);
server = net.createServer((socket) => socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
});
afterEach(() => {
server.close();
});
describe("ctx.network.tcpProbe", () => {
it("refuses without network:probe", async () => {
grants.set("demo", []);
await expect(
contextFor([]).network.tcpProbe("127.0.0.1", port),
).rejects.toThrow(/network:probe/);
});
it("reports an open port and a closed one", async () => {
grants.set("demo", ["network:probe"]);
const ctx = contextFor(["network:probe"]);
expect(await ctx.network.tcpProbe("127.0.0.1", port)).toBe(true);
await new Promise<void>((resolve) => server.close(() => resolve()));
expect(
await ctx.network.tcpProbe("127.0.0.1", port, { timeoutMs: 500 }),
).toBe(false);
});
it("rejects a bad port", async () => {
grants.set("demo", ["network:probe"]);
await expect(
contextFor(["network:probe"]).network.tcpProbe("127.0.0.1", 0),
).rejects.toThrow(/port/);
});
});
describe("ctx.hosts.probeTargets", () => {
it("refuses without hosts:read", async () => {
grants.set("demo", []);
await expect(contextFor([]).hosts.probeTargets()).rejects.toThrow(
/hosts:read/,
);
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:read"]);
await expect(
contextFor(["hosts:read"]).hosts.probeTargets(),
).rejects.toThrow(/acting user/);
});
it("returns own hosts and only shared hosts the actor may connect to", async () => {
grants.set("demo", ["hosts:read"]);
const ctx = contextFor(["hosts:read"]);
const targets = await runAsActor("alice", "request", () =>
ctx.hosts.probeTargets(),
);
expect(targets.map((target) => target.id)).toEqual([1, 2]);
expect(targets[1].ownerUserId).toBe("bob");
});
});
describe("ctx.ssh.probe", () => {
it("refuses without ssh:connect", async () => {
grants.set("demo", []);
await expect(contextFor([]).ssh.probe(1)).rejects.toThrow(/ssh:connect/);
});
it("answers false for a host the actor cannot connect to", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(await runAsActor("alice", "request", () => ctx.ssh.probe(3))).toBe(
false,
);
});
it("probes a reachable host directly", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(
await runAsActor("alice", "request", () => ctx.ssh.probe(2, { port })),
).toBe(true);
});
});
@@ -104,8 +104,8 @@ vi.mock("../../hosts/host-resolver.js", () => ({
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: {
reportLogin: (hostId: number, outcome: unknown) =>
h.logins.push({ hostId, outcome }),
},
@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runExec, type ExecClient } from "../../plugins/ctx-ssh-exec.js";
function fakeClient(
script: (stream: FakeStream) => void,
execError?: Error,
): ExecClient & { commands: string[] } {
const commands: string[] = [];
return {
commands,
exec: (command, callback) => {
commands.push(command);
if (execError) return callback(execError, undefined as never);
const stream = new FakeStream();
callback(undefined, stream as never);
script(stream);
},
};
}
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
written: string[] = [];
closed = false;
write(data: string) {
this.written.push(data);
}
end(data?: string) {
if (data !== undefined) this.written.push(data);
}
close() {
this.closed = true;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("runExec", () => {
it("collects output and the exit code", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.from("out"));
stream.stderr.emit("data", Buffer.from("err"));
stream.emit("close", 3);
});
await expect(runExec(client, "echo hi")).resolves.toEqual({
stdout: "out",
stderr: "err",
code: 3,
truncated: false,
timedOut: false,
});
expect(client.commands).toEqual(["echo hi"]);
});
it("keeps only maxOutputBytes and says so", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.alloc(3000, "a"));
stream.emit("close", 0);
});
const result = await runExec(client, "yes", { maxOutputBytes: 1024 });
expect(result.stdout).toHaveLength(1024);
expect(result.truncated).toBe(true);
});
it("writes stdin and closes it", async () => {
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
stream.emit("close", 0);
});
await runExec(client, "cat >> f", { stdin: "key\n" });
expect(seen?.written).toEqual(["key\n"]);
});
it("stops a command that runs past the time limit", async () => {
vi.useFakeTimers();
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
});
const pending = runExec(client, "sleep 999", { timeoutMs: 2000 });
await vi.advanceTimersByTimeAsync(2100);
const result = await pending;
expect(result).toMatchObject({ code: null, timedOut: true });
expect(seen?.closed).toBe(true);
});
it("rejects when the exec itself fails", async () => {
const client = fakeClient(() => {}, new Error("channel open failure"));
await expect(runExec(client, "ls")).rejects.toThrow(/channel open/);
});
it("rejects an empty or oversized command before running it", async () => {
const client = fakeClient(() => {});
await expect(runExec(client, " ")).rejects.toThrow(/required/);
await expect(runExec(client, "x".repeat(70_000))).rejects.toThrow(
/too long/,
);
expect(client.commands).toEqual([]);
});
});
@@ -0,0 +1,483 @@
/**
* The local file helpers behind ctx.desktop.local.fs. Ported from the old
* Electron local-files tests: path containment, link refusal, publish and
* replace semantics, and the exclusive rename.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertWithinRoot,
createEmptyFile,
defaultPublishFs,
ensureDirectory,
existingPaths,
listDirectory,
makeDirectory,
openDownload,
prepareDownloadPath,
publishDownload,
renameEntry,
renameLocalPath,
walkPaths,
type PublishFs,
} from "../../plugins/desktop-local/local-fs.js";
let root: string;
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-desktop-local-"));
});
afterEach(() => fsp.rm(root, { recursive: true, force: true }));
function windowsLikeFs(
overrides: Partial<PublishFs> = {},
): PublishFs & { renames: Array<[string, string]> } {
const renames: Array<[string, string]> = [];
return {
...defaultPublishFs,
renames,
rename: async (from, to) => {
renames.push([from, to]);
if (fs.existsSync(to)) {
throw Object.assign(new Error("EEXIST: file already exists, rename"), {
code: "EEXIST",
});
}
await defaultPublishFs.rename(from, to);
},
...overrides,
};
}
describe("download destination containment", () => {
const win = path.win32;
const winRoot = "C:\\Downloads\\selected";
it("accepts ordinary nested destinations on Windows", () => {
expect(
assertWithinRoot(winRoot, "C:\\Downloads\\selected\\a.txt", win),
).toBe("C:\\Downloads\\selected\\a.txt");
expect(
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\docs\\2026\\report.pdf",
win,
),
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
expect(
assertWithinRoot(winRoot, "c:\\downloads\\SELECTED\\b.txt", win),
).toBe("c:\\downloads\\SELECTED\\b.txt");
});
it("rejects backslash traversal that normalises out of the root on Windows", () => {
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\..\\outside.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() => assertWithinRoot(winRoot, winRoot, win)).toThrow(
/outside the selected folder/,
);
});
it("rejects absolute, drive-qualified and UNC destinations on Windows", () => {
for (const dest of [
"C:\\Windows\\evil.dll",
"D:\\Downloads\\selected\\x.txt",
"\\\\server\\share\\x.txt",
"C:\\Downloads\\selected-other\\x.txt",
]) {
expect(() => assertWithinRoot(winRoot, dest, win)).toThrow(
/outside the selected folder/,
);
}
});
it("requires a root and rejects POSIX traversal too", () => {
expect(() => assertWithinRoot("", "/tmp/x", path.posix)).toThrow(
/download folder is required/,
);
expect(() =>
assertWithinRoot("/home/max/dl", "/home/max/dl/../x", path.posix),
).toThrow(/outside the selected folder/);
expect(
assertWithinRoot("/home/max/dl", "/home/max/dl/..\\x", path.posix),
).toBe("/home/max/dl/..\\x");
});
it("refuses an escaping download before any disk write", async () => {
const selected = path.join(root, "selected");
await fsp.mkdir(selected);
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "..", "outside.txt"),
transferId: "e1",
}),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
ensureDirectory(path.join(selected, "..", "escaped-dir"), selected),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
openDownload({
rootPath: "",
destPath: path.join(selected, "ok.bin"),
transferId: "e2",
}),
).rejects.toMatchObject({ code: "EINVAL" });
expect(await fsp.readdir(root)).toEqual(["selected"]);
expect(await fsp.readdir(selected)).toEqual([]);
});
});
describe("links inside a download tree", () => {
it("rejects existing linked parents for downloads and directory creation", async () => {
const selected = path.join(root, "selected");
const outside = path.join(root, "outside");
await fsp.mkdir(selected);
await fsp.mkdir(outside);
await fsp.symlink(outside, path.join(selected, "linked"), "junction");
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "linked", "file.bin"),
transferId: "l1",
}),
).rejects.toThrow(/cannot follow links/);
await expect(
ensureDirectory(path.join(selected, "linked", "new"), selected),
).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("allows an explicitly selected linked root", async () => {
const actual = path.join(root, "actual");
const selected = path.join(root, "selected");
await fsp.mkdir(actual);
await fsp.symlink(actual, selected, "junction");
const download = await openDownload({
rootPath: selected,
destPath: path.join(selected, "nested", "file.bin"),
transferId: "l2",
});
download.stream.write("data");
await download.publish();
expect(
await fsp.readFile(path.join(actual, "nested", "file.bin"), "utf8"),
).toBe("data");
});
it("rejects a destination parent replaced by a link before publish", async () => {
const selected = path.join(root, "selected");
const nested = path.join(selected, "nested");
const outside = path.join(root, "outside");
await fsp.mkdir(nested, { recursive: true });
await fsp.mkdir(outside);
const download = await openDownload({
rootPath: selected,
destPath: path.join(nested, "file.bin"),
transferId: "l3",
});
download.stream.write("data");
await fsp.rename(nested, path.join(selected, "original"));
await fsp.symlink(outside, nested, "junction");
await expect(download.publish()).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("prepareDownloadPath refuses a root that is not a directory", async () => {
const file = path.join(root, "file");
await fsp.writeFile(file, "x");
await expect(
prepareDownloadPath(file, path.join(file, "child")),
).rejects.toBeTruthy();
});
});
describe("downloads", () => {
it("refuses to replace an existing file by default", async () => {
const dest = path.join(root, "keep.bin");
await fsp.writeFile(dest, "original");
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "d1" }),
).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect(await fsp.readdir(root)).toEqual(["keep.bin"]);
});
it("replaces an existing file only when overwrite is requested", async () => {
const dest = path.join(root, "replace.bin");
await fsp.writeFile(dest, "original");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d2",
overwrite: true,
});
download.stream.write("fresh");
await download.publish();
expect(await fsp.readFile(dest, "utf8")).toBe("fresh");
expect(await fsp.readdir(root)).toEqual(["replace.bin"]);
});
it("refuses to publish over a file that appeared while it ran", async () => {
const dest = path.join(root, "race.bin");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d3",
});
download.stream.write("mine");
await fsp.writeFile(dest, "someone else wrote this");
await expect(download.publish()).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this");
expect(await fsp.readdir(root)).toEqual(["race.bin"]);
});
it("uses a transfer-unique partial and rejects a concurrent download to the same destination", async () => {
const dest = path.join(root, "same.bin");
const first = await openDownload({
rootPath: root,
destPath: dest,
transferId: "first",
});
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "second" }),
).rejects.toMatchObject({ code: "EBUSY" });
expect(
(await fsp.readdir(root)).filter((n) => n.endsWith(".termix-part")),
).toEqual(["same.bin.first.termix-part"]);
first.stream.write("done");
await first.publish();
expect(await fsp.readdir(root)).toEqual(["same.bin"]);
// The destination is free again once the first one finished.
const again = await openDownload({
rootPath: root,
destPath: path.join(root, "same2.bin"),
transferId: "third",
});
await again.abort();
});
it("allows two downloads of different files side by side and aborts one cleanly", async () => {
const a = await openDownload({
rootPath: root,
destPath: path.join(root, "a.bin"),
transferId: "a",
});
const b = await openDownload({
rootPath: root,
destPath: path.join(root, "b.bin"),
transferId: "b",
});
a.stream.write("A");
b.stream.write("B");
await a.abort();
await b.publish();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
// Abort after publish is harmless.
await b.abort();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
});
});
describe("replace primitive (Windows-safe overwrite)", () => {
const seed = async (name: string, existing: string, fresh: Buffer) => {
const dest = path.join(root, name);
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(dest, existing);
await fsp.writeFile(partial, fresh);
return { dest, partial };
};
it("replaces an existing file without renaming onto an occupied name", async () => {
const fresh = crypto.randomBytes(4096);
const { dest, partial } = await seed("swap.bin", "original", fresh);
const io = windowsLikeFs();
await publishDownload(partial, dest, true, "t1", io);
expect((await fsp.readFile(dest)).equals(fresh)).toBe(true);
expect(await fsp.readdir(root)).toEqual(["swap.bin"]);
expect(io.renames.length).toBeGreaterThan(0);
for (const [, to] of io.renames) {
expect(to === dest || to.endsWith(".termix-replaced")).toBe(true);
}
});
it("restores the original byte for byte when publishing fails", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("restore.bin", "original", fresh);
const denied = () =>
Promise.reject(
Object.assign(new Error("EACCES: permission denied"), {
code: "EACCES",
}),
);
const io = windowsLikeFs({ link: denied, copyFileExcl: denied });
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EACCES" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readdir(root)).sort()).toEqual(
["restore.bin", "restore.bin.t1.termix-part"].sort(),
);
});
it("reports EBUSY and touches nothing when the file cannot be moved aside", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("locked.bin", "original", fresh);
const io = windowsLikeFs({
rename: () =>
Promise.reject(
Object.assign(new Error("EPERM: operation not permitted"), {
code: "EPERM",
}),
),
});
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EBUSY" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readFile(partial)).equals(fresh)).toBe(true);
});
it("refuses to replace a folder with a file", async () => {
const dest = path.join(root, "folder");
await fsp.mkdir(dest);
await fsp.writeFile(path.join(dest, "inner.txt"), "keep");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await expect(
publishDownload(partial, dest, true, "t1", windowsLikeFs()),
).rejects.toMatchObject({ code: "EISDIR" });
expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe(
"keep",
);
});
it("falls back to an exclusive publish when the file to replace is gone", async () => {
const dest = path.join(root, "gone.bin");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await publishDownload(partial, dest, true, "t1", windowsLikeFs());
expect(await fsp.readFile(dest, "utf8")).toBe("new");
expect(await fsp.readdir(root)).toEqual(["gone.bin"]);
});
});
describe("local rename", () => {
it("preserves both files on a name collision", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await fsp.writeFile(b, "destination");
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect(await fsp.readFile(a, "utf8")).toBe("source");
expect(await fsp.readFile(b, "utf8")).toBe("destination");
});
it("refuses to replace even an empty destination directory", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.mkdir(a);
await fsp.mkdir(b);
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect((await fsp.readdir(root)).sort()).toEqual(["a", "b"]);
});
it("renames a file and handles an unchanged name", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await renameLocalPath(a, a);
await renameLocalPath(a, b);
expect(fs.existsSync(a)).toBe(false);
expect(await fsp.readFile(b, "utf8")).toBe("source");
});
it("renameEntry only takes a plain name in the same folder", async () => {
const a = path.join(root, "a");
await fsp.writeFile(a, "x");
await expect(renameEntry(a, "../escape")).rejects.toThrow(/Invalid/);
await expect(renameEntry(a, "")).rejects.toThrow(/Invalid/);
expect(await renameEntry(a, "c")).toBe(path.join(root, "c"));
});
});
describe("listing and creating", () => {
it("lists entries with type, size and hidden flag", async () => {
await fsp.mkdir(path.join(root, "dir"));
await fsp.writeFile(path.join(root, "f.txt"), "abc");
await fsp.writeFile(path.join(root, ".hidden"), "");
const listing = await listDirectory(root);
const byName = Object.fromEntries(listing.entries.map((e) => [e.name, e]));
expect(byName["dir"].type).toBe("directory");
expect(byName["f.txt"]).toMatchObject({ type: "file", size: 3 });
expect(byName[".hidden"].hidden).toBe(true);
expect(listing.parent).toBe(path.dirname(root));
});
it("rejects relative paths", async () => {
await expect(listDirectory("relative")).rejects.toThrow(/absolute/);
});
it("creates folders and files without replacing anything", async () => {
expect(await makeDirectory(root, "new")).toBe(path.join(root, "new"));
await expect(makeDirectory(root, "new")).rejects.toBeTruthy();
expect(await createEmptyFile(root, "a.txt")).toBe(path.join(root, "a.txt"));
await fsp.writeFile(path.join(root, "a.txt"), "keep");
await expect(createEmptyFile(root, "a.txt")).rejects.toBeTruthy();
expect(await fsp.readFile(path.join(root, "a.txt"), "utf8")).toBe("keep");
});
it("reports which paths exist", async () => {
await fsp.writeFile(path.join(root, "yes"), "");
expect(
await existingPaths([path.join(root, "yes"), path.join(root, "no")]),
).toEqual([path.join(root, "yes")]);
});
it("walks dropped paths into a flat list with empty folders", async () => {
await fsp.mkdir(path.join(root, "tree", "empty"), { recursive: true });
await fsp.mkdir(path.join(root, "tree", "sub"));
await fsp.writeFile(path.join(root, "tree", "sub", "a.txt"), "12345");
await fsp.writeFile(path.join(root, "single.txt"), "xy");
const result = await walkPaths([
path.join(root, "tree"),
path.join(root, "single.txt"),
]);
expect(result.files.map((f) => f.relativePath).sort()).toEqual([
"single.txt",
"tree/sub/a.txt",
]);
expect(result.emptyDirs).toEqual(["tree/empty"]);
expect(result.totalBytes).toBe(7);
});
});
@@ -0,0 +1,85 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
repairSpawnHelpers,
resolveLocalShell,
} from "../../plugins/desktop-local/local-pty.js";
describe("resolveLocalShell", () => {
it("starts the default WSL distribution without PowerShell arguments", () => {
expect(resolveLocalShell("win32", "wsl", {})).toEqual({
file: "wsl.exe",
args: [],
});
});
it("keeps PowerShell as the default Windows shell", () => {
expect(resolveLocalShell("win32", "default", {})).toEqual({
file: "powershell.exe",
args: ["-NoLogo"],
});
});
it("preserves the configured shell on non-Windows platforms", () => {
expect(
resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" }, () => true),
).toEqual({
file: "/bin/fish",
args: ["-l"],
});
});
it("skips a configured shell that is not installed", () => {
const installed = new Set(["/bin/bash", "/bin/zsh"]);
expect(
resolveLocalShell(
"darwin",
undefined,
{ SHELL: "/opt/homebrew/bin/fish" },
(file) => installed.has(file),
).file,
).toBe("/bin/zsh");
});
});
describe("repairSpawnHelpers", () => {
it("restores the execute bit in the unpacked copy only when it is missing", () => {
const modes = new Map<string, number>();
const chmods: Array<[string, number]> = [];
const fsImpl = {
statSync: (file: string) => {
const mode = modes.get(file);
if (mode === undefined) throw new Error("ENOENT");
return { mode };
},
chmodSync: (file: string, mode: number) => {
chmods.push([file, mode]);
},
};
const dir =
"/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty";
const unpacked = dir.replace("app.asar", "app.asar.unpacked");
const release = `${unpacked}/build/Release/spawn-helper`;
modes.set(release.split("/").join(path.sep), 0o644);
modes.set(release, 0o644);
expect(repairSpawnHelpers(dir, fsImpl as never)).toBe(1);
expect(chmods[0][1] & 0o111).not.toBe(0);
expect(chmods[0][0]).toContain("app.asar.unpacked");
});
it.each(["app-arm64.asar", "app-x64.asar"])(
"repairs the helper in the unpacked copy of a universal build's %s",
(asar) => {
const chmods: string[] = [];
const fsImpl = {
statSync: () => ({ mode: 0o644 }),
chmodSync: (file: string) => {
chmods.push(file.split(path.sep).join("/"));
},
};
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
repairSpawnHelpers(dir, fsImpl as never);
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
},
);
});
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
hostStatus,
provideHostStatus,
registerBuiltinHostStatusProvider,
type HostStatusProvider,
} from "../../plugins/host-status-provider.js";
function fakeProvider(label: string): HostStatusProvider & {
ports: string[];
} {
const ports: string[] = [];
return {
ports,
start: vi.fn(),
stop: vi.fn(),
get: (hostId) => ({ status: "online", lastChecked: `${label}:${hostId}` }),
statusesFor: async () => new Map(),
refresh: vi.fn(async () => {}),
check: async (hostId) => ({
status: "offline",
lastChecked: `${label}:${hostId}`,
}),
reportLogin: vi.fn(),
registerPort: (type) => {
ports.push(`+${type}`);
return () => ports.push(`-${type}`);
},
getIntervalSeconds: async () => 45,
setIntervalSeconds: vi.fn(async () => {}),
};
}
beforeEach(() => {
hostStatus.stop();
registerBuiltinHostStatusProvider(null);
});
describe("host status provider", () => {
it("answers unknown when nothing provides status", async () => {
expect(hostStatus.get(1)).toBeNull();
expect((await hostStatus.statusesFor("u", null)).size).toBe(0);
expect(await hostStatus.check(1)).toBeNull();
expect(await hostStatus.getIntervalSeconds()).toBe(30);
});
it("uses the built-in until a plugin provides", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(hostStatus.get(1)?.lastChecked).toBe("plugin:1");
remove();
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
});
it("refuses a second provider", () => {
const remove = provideHostStatus("one", fakeProvider("one"));
expect(() => provideHostStatus("two", fakeProvider("two"))).toThrow(
/already provided by plugin "one"/,
);
remove();
});
it("hands lifecycle over when the provider changes", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
hostStatus.start();
expect(builtin.start).toHaveBeenCalledTimes(1);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.stop).toHaveBeenCalledTimes(1);
expect(plugin.start).toHaveBeenCalledTimes(1);
remove();
expect(plugin.stop).toHaveBeenCalledTimes(1);
expect(builtin.start).toHaveBeenCalledTimes(2);
});
it("keeps registered ports across a provider change", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
const drop = hostStatus.registerPort("rdp", () => 3389);
expect(builtin.ports).toEqual(["+rdp"]);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.ports).toEqual(["+rdp", "-rdp"]);
expect(plugin.ports).toEqual(["+rdp"]);
drop();
expect(plugin.ports).toEqual(["+rdp", "-rdp"]);
remove();
});
});
@@ -1078,3 +1078,25 @@ describe("layouts and themes", () => {
expect(has('duplicates "dusk"')).toBe(true);
});
});
describe("manifest runtime", () => {
it("accepts a desktop runtime plugin", () => {
expect(
validateManifest(
base({ runtime: "desktop", capabilities: ["desktop:local"] }),
),
).toEqual([]);
});
it("rejects an unknown runtime", () => {
expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch(
/runtime/,
);
});
it("rejects a desktop runtime that is mirrored or server only", () => {
expect(
validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(),
).toMatch(/local/);
});
});
@@ -200,6 +200,10 @@ describe("assertOwnedTables", () => {
const owners = Object.entries(LEGACY_TABLE_OWNERS);
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
});
it("lets the homepage plugin adopt recent_activity", () => {
expect(LEGACY_TABLE_OWNERS.recent_activity).toBe("homepage");
});
});
describe("applyPluginMigrations", () => {
@@ -0,0 +1,127 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../plugins/probe/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(
(
_srcIp: string,
_srcPort: number,
_host: string,
_port: number,
callback: (error: Error | null, stream?: { destroy(): void }) => void,
) => {
callback(
reachable ? null : new Error("refused"),
reachable ? { destroy: () => {} } : undefined,
);
},
),
});
return client;
}
afterEach(() => {
vi.useRealTimers();
});
describe("JumpProbePool", () => {
it("shares one chain across hosts behind the same hops", async () => {
const client = fakeClient();
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
const hops = [{ hostId: 1 }, { hostId: 2 }];
const results = await Promise.all([
pool.ping(hops, "u1", "10.0.0.3", 22),
pool.ping(hops, "u1", "10.0.0.4", 22),
pool.ping(hops, "u1", "10.0.0.5", 22),
]);
expect(results).toEqual([true, true, true]);
expect(open).toHaveBeenCalledTimes(1);
expect(client.forwardOut).toHaveBeenCalledTimes(3);
expect(client.end).not.toHaveBeenCalled();
pool.closeAll();
});
it("keeps chains apart per user and hop list", async () => {
const open = vi.fn(async () => fakeClient() as never);
const pool = new JumpProbePool(open);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
it("reports an unreachable target without dropping the chain", async () => {
const client = fakeClient(false);
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
expect(open).toHaveBeenCalledTimes(1);
pool.closeAll();
});
it("drops a chain whose hop stops answering", async () => {
vi.useFakeTimers();
const stale = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(),
});
const fresh = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(fresh);
const pool = new JumpProbePool(open);
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
await vi.advanceTimersByTimeAsync(1000);
expect(await first).toBe(false);
expect(stale.end).toHaveBeenCalled();
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
expect(open).toHaveBeenCalledTimes(2);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fakeClient());
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
pool.closeAll();
});
it("opens a new chain after the old one closed or sat idle", async () => {
vi.useFakeTimers();
const first = fakeClient();
const second = fakeClient();
const third = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce(third);
const pool = new JumpProbePool(open, 1000);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
first.emit("close");
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1500);
expect(second.end).toHaveBeenCalled();
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
});
@@ -0,0 +1,57 @@
import type { Client } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { tcpPingThroughJumpHost } from "../../../plugins/probe/tcp-ping.js";
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
detectSshKeyType,
generateSshKeyPair,
publicKeyFromPrivate,
validateSshKeyPair,
} from "../../../../packages/plugin-sdk/src/ssh-keys.js";
describe("plugin SDK ssh-keys", () => {
it("generates a usable ed25519 pair", () => {
const pair = generateSshKeyPair("ssh-ed25519", { comment: "test" });
expect(pair.publicKey).toMatch(/^ssh-ed25519 /);
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-ed25519");
expect(detectSshKeyType(pair.publicKey)).toBe("ssh-ed25519");
expect(validateSshKeyPair(pair.privateKey, pair.publicKey)).toEqual({
isValid: true,
});
});
it("derives the public key and ignores the comment when matching", () => {
const pair = generateSshKeyPair("ecdsa-sha2-nistp256");
const derived = publicKeyFromPrivate(pair.privateKey);
expect(derived).toMatch(/^ecdsa-sha2-nistp256 /);
expect(
validateSshKeyPair(pair.privateKey, `${derived} someone@host`).isValid,
).toBe(true);
});
it("generates RSA with a minimum size", () => {
expect(() => generateSshKeyPair("ssh-rsa", { bits: 512 })).toThrow(/2048/);
const pair = generateSshKeyPair("ssh-rsa", { bits: 2048 });
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-rsa");
});
it("works with a passphrase and fails without it", () => {
const pair = generateSshKeyPair("ssh-ed25519", { passphrase: "secret" });
expect(publicKeyFromPrivate(pair.privateKey)).toBeNull();
expect(publicKeyFromPrivate(pair.privateKey, "secret")).toBe(
pair.publicKey.split(" ").slice(0, 2).join(" "),
);
});
it("flags a pair that does not belong together", () => {
const a = generateSshKeyPair("ssh-ed25519");
const b = generateSshKeyPair("ssh-ed25519");
const result = validateSshKeyPair(a.privateKey, b.publicKey);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/match/);
});
it("reports garbage as unreadable", () => {
expect(detectSshKeyType("not a key")).toBeNull();
expect(publicKeyFromPrivate("not a key")).toBeNull();
expect(validateSshKeyPair("x", "y").isValid).toBe(false);
});
it("rejects an unknown key type", () => {
expect(() => generateSshKeyPair("ssh-dss" as never)).toThrow(/Unsupported/);
});
});
@@ -79,6 +79,7 @@ const UNGATED: Record<string, string> = {
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"desktop.local.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
"ssh.poolKey": "a string built from a host the plugin already holds",
"ssh.dropPooled": "closes only this plugin's own pooled connections",
@@ -162,7 +163,15 @@ describe("every privileged ctx member is guarded", () => {
let caught: unknown;
await runAsActor(ACTOR, "request", async () => {
try {
await member.fn(...PLACEHOLDER_ARGS);
const result = await member.fn(...PLACEHOLDER_ARGS);
// An async generator only runs its guard on the first next().
if (
result &&
typeof (result as AsyncGenerator).next === "function" &&
Symbol.asyncIterator in (result as object)
) {
await (result as AsyncGenerator).next();
}
} catch (error) {
caught = error;
}