mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move desktop, status, import, audit and branding features into plugins
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user