* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-29 17:59:55 -05:00
1 parent 73c15f7c58
commit f90621a54e
168 files changed
+35063 -339

No files matched your search

@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
folderAndParents,
SharedFolderAuthOverrideRepository,
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
import { TestSqliteDatabase } from "./test-support.js";
describe("folderAndParents", () => {
it("lists the folder, then each folder above it", () => {
expect(folderAndParents("Prod / Web / EU")).toEqual([
"Prod / Web / EU",
"Prod / Web",
"Prod",
]);
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
expect(folderAndParents("")).toEqual([]);
expect(folderAndParents(null)).toEqual([]);
});
});
describe("SharedFolderAuthOverrideRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(onWrite?: () => void) {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (7, 'recipient', 'cred-seven', 'password'),
(8, 'recipient', 'cred-eight', 'password');
`);
return new SharedFolderAuthOverrideRepository(context, onWrite);
}
it("sets, updates and clears a login per user, folder and protocol", async () => {
let writes = 0;
const repository = await createRepository(() => {
writes += 1;
});
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod", "ssh", 8);
await repository.setCredential("recipient", "Prod", "rdp", 7);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBe(8);
await expect(
repository.findCredentialId("other", "Prod", "ssh"),
).resolves.toBeNull();
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(true);
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(false);
expect(writes).toBe(4);
});
it("finds the login of the closest folder above a host", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
await expect(
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
await expect(
repository.findClosest("recipient", "Prod / DB", "ssh"),
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
await expect(
repository.findClosest("recipient", "Staging", "ssh"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", "Prod", "rdp"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", null, "ssh"),
).resolves.toBeNull();
});
it("drops the login when its credential is deleted", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBeNull();
});
});
@@ -214,3 +214,43 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,6 +8,7 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
@@ -488,6 +488,35 @@ describe("plugins route", () => {
}
});
it("lists a plugin's engine ranges to any user", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({
manifestJson: JSON.stringify({
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
}),
}),
);
state.plugins.set(
"bad-engine",
makePlugin({
id: "bad-engine",
manifestJson: JSON.stringify({ engine: { api: 2 } }),
}),
);
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
api: "^1.13",
termix: ">=26.10.6",
});
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
@@ -643,6 +672,7 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
runsOnServer: false,
engine: {},
contributes: null,
dependencies: {},
optionalDependencies: {},
@@ -8,9 +8,14 @@ const state = vi.hoisted(() => ({
isShared: true,
isAdminBypass: false,
},
credentialOwned: true,
credentialUsable: true,
credentialId: 7 as number | null,
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
folderWrites: [] as Array<{
folder: string;
protocol: string;
credentialId: number | null;
}>,
auditCalls: [] as Array<Record<string, unknown>>,
}));
@@ -70,11 +75,12 @@ vi.mock("../../../utils/permission-manager.js", () => ({
},
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentialUsable ? { id: credentialId } : null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
findByIdForUser: async () =>
state.credentialOwned ? { id: state.credentialId } : null,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.credentialId,
setCredential: async (
@@ -96,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
return true;
},
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findCredentialId: async () => 7,
setCredential: async (
_userId: string,
folder: string,
protocol: string,
credentialId: number,
) => {
state.folderWrites.push({ folder, protocol, credentialId });
},
clearCredential: async (
_userId: string,
folder: string,
protocol: string,
) => {
state.folderWrites.push({ folder, protocol, credentialId: null });
return true;
},
}),
createCurrentUserRepository: () => ({
findById: async () => ({ id: "recipient", username: "recipient" }),
}),
@@ -108,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
getAuditUsername: async (userId: string) => userId,
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
state.auditCalls.push(entry);
}),
@@ -133,6 +159,8 @@ describe("shared host authentication override routes", () => {
method: "get" | "put",
body: Record<string, unknown> = {},
protocol = "ssh",
path = "/host-access/:hostId/auth/:protocol",
query: Record<string, unknown> = {},
): Promise<{ status: number; body: unknown }> {
const routeLayer = (
router as unknown as {
@@ -151,15 +179,14 @@ describe("shared host authentication override routes", () => {
}>;
}
).stack.find(
(layer) =>
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
layer.route.methods[method],
(layer) => layer.route?.path === path && layer.route.methods[method],
);
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
const req = {
params: { hostId: "42", protocol },
query,
body,
headers: {},
ip: "127.0.0.1",
@@ -206,12 +233,106 @@ describe("shared host authentication override routes", () => {
isShared: true,
isAdminBypass: false,
};
state.credentialOwned = true;
state.credentialUsable = true;
state.credentialId = 7;
state.writes = [];
state.folderWrites = [];
state.auditCalls = [];
});
describe("folder logins", () => {
const FOLDER = "/folder-auth/:protocol";
it("reads the login for a folder", async () => {
const response = await invoke("get", {}, "ssh", FOLDER, {
folder: " Prod / Web ",
});
expect(response).toEqual({
status: 200,
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
});
});
it("sets and clears the login and audits both", async () => {
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: null },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(state.folderWrites).toEqual([
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
{ folder: "Prod", protocol: "ssh", credentialId: null },
]);
expect(state.auditCalls.map((call) => call.action)).toEqual([
"set_shared_folder_auth_override",
"clear_shared_folder_auth_override",
]);
});
it("rejects a bad folder, protocol or credential", async () => {
expect(
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "x".repeat(300), credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"smtp",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: -1 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
state.credentialUsable = false;
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 99 },
"ssh",
FOLDER,
)
).status,
).toBe(404);
expect(state.folderWrites).toEqual([]);
});
});
it("returns the current override for a role-derived shared recipient", async () => {
const response = await invoke("get");
expect(response).toEqual({
@@ -254,7 +375,7 @@ describe("shared host authentication override routes", () => {
const invalidResponse = await invoke("put", { credentialId: 0 });
expect(invalidResponse.status).toBe(400);
state.credentialOwned = false;
state.credentialUsable = false;
const foreignResponse = await invoke("put", { credentialId: 99 });
expect(foreignResponse.status).toBe(404);
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { pruneHostGrants, pruneCredentialGrants } =
const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [
@@ -105,3 +105,27 @@ describe("share pruning", () => {
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
});
});
describe("grantsForFolderRule", () => {
const entries = [
{ id: 1, userId: "bob", roleId: null },
{ id: 2, userId: null, roleId: 7 },
{ id: 3, userId: "carol", roleId: null },
];
it("picks the grants of the rule's user or role", () => {
expect(
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
(e) => e.id,
),
).toEqual([1]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
(e) => e.id,
),
).toEqual([2]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: null }),
).toEqual([]);
});
});
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash: string;
}
>(),
adminCount: 1,
deleted: [] as string[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("bcryptjs", () => ({
default: {
compare: async (password: string, hash: string) =>
hash === `hash:${password}`,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
countAdmins: async () => state.adminCount,
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
state.deleted.push(userId);
},
}));
const { registerUserDeleteAccountRoutes } =
await import("../../../database/routes/user-delete-account-routes.js");
let handler: RequestHandler | null = null;
registerUserDeleteAccountRoutes(
{
delete: (_path: string, ...handlers: RequestHandler[]) => {
handler = handlers[handlers.length - 1];
},
} as unknown as import("express").Router,
(_req, _res, next) => next(),
);
async function deleteAccount(userId: string, body: Record<string, unknown>) {
const req = { userId, body } as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as Record<string, unknown> | null,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: Record<string, unknown>) {
this.jsonBody = payload;
return this;
},
};
await handler!(req, res as unknown as Response, () => {});
return res;
}
beforeEach(() => {
state.adminCount = 1;
state.deleted = [];
state.users = new Map([
[
"u1",
{
id: "u1",
username: "user",
isAdmin: false,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"sso1",
{
id: "sso1",
username: "sso",
isAdmin: false,
isExternal: true,
passwordHash: "hash:secret",
},
],
]);
});
describe("DELETE /delete-account", () => {
it("runs the full user cleanup", async () => {
const res = await deleteAccount("u1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["u1"]);
});
it("needs a password", async () => {
const res = await deleteAccount("u1", {});
expect(res.statusCode).toBe(400);
expect(state.deleted).toEqual([]);
});
it("refuses a wrong password", async () => {
const res = await deleteAccount("u1", { password: "nope" });
expect(res.statusCode).toBe(401);
expect(state.deleted).toEqual([]);
});
it("refuses external accounts", async () => {
const res = await deleteAccount("sso1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("refuses the last admin", async () => {
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("lets an admin go when another admin is left", async () => {
state.adminCount = 2;
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["admin1"]);
});
it("404s for an unknown user", async () => {
const res = await deleteAccount("ghost", { password: "secret" });
expect(res.statusCode).toBe(404);
});
});
@@ -37,6 +37,29 @@ afterEach(() => {
});
describe("ssh transport providers", () => {
it("tells providers the owner, sync id and origin of the host", async () => {
const claims = vi.fn(() => false);
provideSshTransport("a", { claims, open: vi.fn() });
await openSshTransport(
{
...host,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
} as never,
{} as never,
).catch(() => {});
expect(claims).toHaveBeenCalledWith(7, {
id: 7,
ip: "10.0.0.7",
port: 22,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
localOnly: false,
});
});
it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull();
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
isAdminBypass: false,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
sharedCredentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[],
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
@@ -37,6 +38,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ??
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
@@ -125,6 +133,7 @@ beforeEach(() => {
state.isAdminBypass = false;
state.overrideCredentialId = null;
state.credentials.clear();
state.sharedCredentials.clear();
state.auditCalls = [];
state.sharedSecret = null;
state.activeHostAccessId = null;
@@ -376,6 +385,61 @@ describe("resolveHostById", () => {
expect(host.username).toBe("shared-login");
});
it("uses a credential shared with the recipient as their personal override", async () => {
state.overrideCredentialId = 7;
state.sharedCredentials.set("7:recipient", {
id: 7,
username: "shared-cred-user",
password: "shared-cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.username).toBe("shared-cred-user");
expect(host.password).toBe("shared-cred-pass");
});
it("uses the host's credential when it is also shared with the recipient", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
state.sharedCredentials.set("1:recipient", {
id: 1,
username: "deploy",
password: null,
privateKey: "OWNER-KEY-COPY",
key: null,
keyPassword: null,
keyType: "ssh-ed25519",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.authType).toBe("key");
expect(host.key).toBe("OWNER-KEY-COPY");
expect(host.username).toBe("deploy");
});
it("denies a recipient when the host's credential is not shared with them", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
expect(await resolveHostById(42, "recipient")).toBeNull();
});
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
expect(await resolveHostById(42, "recipient")).toBeNull();
});
+19
View File
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(),
resolveHostBySyncId: vi.fn(),
openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>),
}));
@@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => {
});
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
resolveHostBySyncId: mocks.resolveHostBySyncId,
}));
vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport,
@@ -114,6 +116,23 @@ describe("ssh relay", () => {
await vi.waitFor(() => expect(end).toHaveBeenCalled());
});
it("finds the host by sync id for a linked desktop", async () => {
mocks.resolveHostBySyncId.mockResolvedValue({
id: 9,
ip: "127.0.0.1",
port: echoPort,
});
const { socket } = await open("?syncId=abc");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
expect(mocks.resolveHostById).not.toHaveBeenCalled();
socket.close();
});
it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5");
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest";
import {
parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand,
parseUpgradable,
execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => {
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
});
});
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
destroy() {}
}
function fakeClient(script: (stream: FakeStream) => void) {
return {
exec: (
_command: string,
_options: unknown,
callback: (err: Error | undefined, stream: FakeStream) => void,
) => {
const stream = new FakeStream();
callback(undefined, stream);
script(stream);
},
} as never;
}
// "The system cannot find the path specified." as the GBK bytes a
// Chinese-locale Windows console actually sends.
const GBK_PATH_ERROR = Buffer.from(
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
"hex",
);
describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^Command timeout after 5ms$/,
);
});
it("decodes GBK stderr from a localized Windows host", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
stream.emit("close", 1);
}),
"dir",
);
expect(result.stderr).toBe("系统找不到指定的路径。");
expect(result.code).toBe(1);
});
it("reassembles a UTF-8 character split across chunks", async () => {
const utf8 = Buffer.from("以太网 2", "utf8");
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
stream.emit("data", utf8.subarray(4));
stream.emit("close", 0);
}),
"ipconfig",
);
expect(result.stdout).toBe("以太网 2");
});
it("passes plain ASCII output through unchanged", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", Buffer.from("ok\n"));
stream.stderr.emit("data", Buffer.from("warn\n"));
stream.emit("close", 0);
}),
"echo ok",
);
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
});
});
describe("decodeCommandOutput", () => {
it("keeps valid UTF-8, including the empty buffer", () => {
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK when the bytes are not valid UTF-8", () => {
// "以太网 2" in GBK, the interface name of the metrics widget issue.
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
});
it("keeps lossy UTF-8 when no decoder matches", () => {
// 0xff is a valid byte in neither UTF-8 nor GBK.
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
});
});
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
isBareSpecifier,
isUserPluginFile,
shouldUseHostModule,
} from "../../plugins/host-modules.js";
describe("isBareSpecifier", () => {
@@ -46,6 +47,36 @@ describe("isUserPluginFile", () => {
});
});
describe("shouldUseHostModule", () => {
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
const parent = pathToFileURL(
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
).href;
it("falls back for import and require misses from a plugin", () => {
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
}
});
it("leaves other errors and files alone", () => {
expect(
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
).toBe(false);
expect(
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
).toBe(false);
expect(
shouldUseHostModule(
{ code: "MODULE_NOT_FOUND" },
"axios",
undefined,
root,
),
).toBe(false);
});
});
describe("installHostModuleFallback", () => {
let dataDir: string | null = null;
@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -0,0 +1,400 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -0,0 +1,130 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});
+51
View File
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
});
it("refuses a signature that does not match the file", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
});
it("keeps a signature from a non official key unverified", async () => {
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
try {
await boot();
await manage.setDeveloperMode(true);
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
const buffer = await artifactBuffer("notes");
const digest = crypto.createHash("sha256").update(buffer).digest();
const signature = crypto
.sign(null, digest, privateKey)
.toString("base64");
const preview = await manage.stageUpload(buffer, signature);
expect(preview.official).toBe(false);
await manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
});
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
} finally {
setCustomPluginKeys([]);
}
});
it("refuses a file that is not a plugin archive", async () => {
await boot();
await manage.setDeveloperMode(true);
+104
View File
@@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => {
});
});
describe("contributes.mobile", () => {
const tabs = [
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
];
it("accepts each mode with its own fields", () => {
for (const mobile of [
{
mode: "webview",
entry: "dist/mobile.js",
sizes: ["phone", "tablet"],
views: ["sample"],
note: "mobile.note",
},
{ mode: "webview" },
{ mode: "native", feature: "file-manager" },
{ mode: "none", note: "Not on phones" },
]) {
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
[],
);
}
});
it("needs a known mode and rejects unknown fields", () => {
const errors = validateManifest(
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
).join();
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
expect(
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
).toMatch(/"contributes\.mobile" must be an object/);
});
it("keeps webview and native fields to their own mode", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
mobile: {
mode: "native",
entry: "dist/mobile.js",
views: ["sample"],
},
},
}),
).join();
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
).join(),
).toMatch(/mobile\.feature is only for mode "native"/);
});
it("puts the entry beside the frontend bundle", () => {
const check = (entry: string, frontend?: string) =>
validateManifest(
base({
...(frontend ? { frontend } : {}),
contributes: { mobile: { mode: "webview", entry } },
}),
).join();
expect(check("dist/mobile.js")).toBe("");
expect(check("./dist/mobile.js")).toBe("");
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
});
it("checks sizes, views and the feature id", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
panels: [{ id: "side", titleKey: "nav.side" }],
mobile: {
mode: "webview",
sizes: ["phone", "phone", "watch"],
views: ["sample", "side", "missing"],
},
},
}),
).join();
expect(errors).toMatch(/sizes duplicates "phone"/);
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
expect(errors).toMatch(/views entries must be ids .*"missing"/);
expect(errors).not.toMatch(/"side"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
).join(),
).toMatch(/sizes must be a non-empty array/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
).join(),
).toMatch(/mobile\.feature must match/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
expect(getEntity("thing")?.readOnly).toBeUndefined();
expect(getEntity("thing")?.load).toBeUndefined();
});
it("lets a plugin sync rows it builds as read only copies", () => {
const load = async () => [];
registerEntity("demo", {
type: "sharedIcons",
table: null,
readOnly: true,
load,
write: async () => undefined,
erase: async () => undefined,
});
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
});
it("refuses a plugin entity that loads rows but can be pushed", () => {
expect(() =>
registerEntity("demo", {
type: "pushable",
table: null,
load: async () => [],
write: async () => undefined,
erase: async () => undefined,
}),
).toThrow(/readOnly/);
expect(() =>
registerEntity("demo", {
type: "halfDone",
table: null,
readOnly: true,
load: async () => [],
}),
).toThrow(/load, write and erase/);
expect(() =>
registerEntity("demo", { type: "nothing", table: null }),
).toThrow(/table or load/);
});
it("drops core only keys a plugin puts on its registration", () => {
registerEntity("demo", {
type: "sneaky",
table: {},
covers: () => true,
canReference: async () => true,
hashIgnore: ["x"],
} as never);
const entity = getEntity("sneaky")!;
expect(entity.covers).toBeUndefined();
expect(entity.canReference).toBeUndefined();
expect(entity.hashIgnore).toBeUndefined();
});
});
describe("core sync entities", () => {
+25 -2
View File
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
getLink: vi.fn(),
generateJWTToken: vi.fn(),
revokeSession: vi.fn(),
getEntity: vi.fn(),
findStoredIds: vi.fn(),
}));
vi.mock("../../utils/auth-manager.js", () => ({
@@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
getEntity: mocks.getEntity,
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(),
}));
@@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../sync/store.js", () => ({}));
vi.mock("../../sync/store.js", () => ({
findStoredIds: mocks.findStoredIds,
}));
vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges,
@@ -215,3 +219,22 @@ describe("host.links", () => {
});
});
});
describe("host.entities.idsOf", () => {
it("maps sync ids to the user's own row ids", async () => {
const entity = { type: "snippets", table: {} };
mocks.getEntity.mockReturnValue(entity);
mocks.findStoredIds.mockResolvedValue({ a: 3 });
expect(
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
).toEqual({ a: 3 });
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
});
it("answers nothing for an unknown or built type", async () => {
mocks.getEntity.mockReturnValue(undefined);
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { info: vi.fn(), error: vi.fn() },
}));
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
const TEMPLATE = [
"listen ${PORT};",
"listen ${SSL_PORT} ssl;",
"ssl_certificate ${SSL_CERT_PATH};",
" root /app/html;",
"pid /tmp/nginx/nginx.pid;",
].join("\n");
const values = {
port: "8080",
sslPort: "443",
certPath: "/data/ssl/termix.crt",
keyPath: "/data/ssl/termix.key",
};
describe("renderNginxTemplate", () => {
it("fills the ports and certificate", () => {
const out = renderNginxTemplate(TEMPLATE, values);
expect(out).toContain("listen 8080;");
expect(out).toContain("listen 443 ssl;");
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
expect(out).toContain("root /app/html;");
});
it("uses the install folder in place of /app", () => {
const out = renderNginxTemplate(TEMPLATE, {
...values,
appDir: "/opt/termix/",
});
expect(out).toContain("root /opt/termix/html;");
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
});
});
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hostOverride: null as number | null,
folderOverride: null as { folder: string; credentialId: number } | null,
ownerSecret: null as Record<string, unknown> | null,
usable: new Set<number>(),
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
}));
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) =>
state.usable.has(id) ? { id, username: `user-${id}` } : null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
findActiveHostAccess: async () => null,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.hostOverride,
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
state.folderLookups.push({ folder, protocol });
return state.folderOverride;
},
}),
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
getSecretForUser: async () => state.ownerSecret,
snapshotForUser: async () => undefined,
}),
},
}));
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const host = (extra: Record<string, unknown> = {}) =>
({
id: 42,
userId: "owner",
folder: "Prod / Web",
authType: "credential",
credentialId: 99,
shareSshAuth: false,
...extra,
}) as never;
beforeEach(() => {
state.hostOverride = null;
state.folderOverride = null;
state.ownerSecret = null;
state.usable = new Set();
state.folderLookups = [];
});
describe("folder logins for shared hosts", () => {
it("uses the folder's login when the host has none of its own", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({
source: "personal-override",
credentialId: 7,
folder: "Prod",
});
expect(state.folderLookups).toEqual([
{ folder: "Prod / Web", protocol: "ssh" },
]);
});
it("lets the host's own login win over the folder's", async () => {
state.hostOverride = 5;
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(5).add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ credentialId: 5 });
expect(result).not.toHaveProperty("folder");
});
it("skips a folder login the user can no longer use", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.ownerSecret = { authType: "password", password: "x" };
const result = await resolveRecipientSharedHostAuthentication(
host({ shareSshAuth: true }),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ source: "owner-shared" });
});
it("asks for a login when nothing is set", async () => {
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toEqual({ source: "required" });
});
});
@@ -17,8 +17,14 @@ const state = vi.hoisted(() => ({
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findUsableCredential: async (credentialId: number, userId: string) => {
const credential = state.credentials.get(credentialId);
if (!credential) return null;
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
return credential.userId === userId || sharedWith.includes(userId)
? credential
: null;
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -29,8 +35,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
findCredentialByIdForUser: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(credentialId)?.userId === userId
? state.credentials.get(credentialId)
: null,
}),
createCurrentSharedHostSecretsRepository: () => ({
upsert: async (row: Record<string, unknown>) => {
@@ -260,6 +268,39 @@ describe("SharedHostSecretsManager", () => {
});
});
it("snapshots SSH auth from a credential shared with the host owner", async () => {
state.credentials.set(123, {
id: 123,
userId: "cred-owner",
sharedWith: ["owner"],
username: "cred-user",
authType: "password",
password: "cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
state.hosts.set(
42,
baseHost({
authType: "credential",
credentialId: 123,
shareSshAuth: true,
password: null,
}),
);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
username: "cred-user",
authType: "password",
password: "cred-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, {
id: 55,
@@ -11,6 +11,7 @@ import {
getFriendlyKeyTypeName,
validateKeyPair,
isSecurityKeyFile,
clearUnlockedKeyCache,
} from "../../utils/ssh-key-utils.js";
const { parsePrivateKey } = sshpk;
@@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => {
});
});
describe("encrypted OpenSSH keys", () => {
const key = sshpk.generatePrivateKey("ed25519");
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
clearUnlockedKeyCache();
const first = preparePrivateKeyForSSH2(encrypted, "pw");
const parsed = ssh2.utils.parseKey(first);
expect(parsed).not.toBeInstanceOf(Error);
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
key.fingerprint().toString(),
);
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
first.toString(),
);
});
it("leaves the key locked for a wrong or missing passphrase", () => {
clearUnlockedKeyCache();
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
encrypted,
);
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
});
});
describe("security key files", () => {
const str = (v: string | Buffer) => {
const b = Buffer.from(v);