mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
v26.10.6 (#1739)
* 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:
168 files changed
+35063
-339
No files matched your search
+98
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user