feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import type { MetricsViewersV1 } from "../../src/backend/index.js";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
import { manifest, pluginDir, startServer, type TestServer } from "./server";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("host-metrics activate", () => {
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (missing) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((cap) => cap !== missing),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
},
);
it("offers viewers as a service that runs as the caller", async () => {
server = await startServer();
const viewers = server.mock.services.get(
"host-metrics.viewers",
) as MetricsViewersV1;
const registered = await server.mock.ctx.asUser("user-1", () =>
viewers.register(7),
);
expect(registered).toEqual({ viewerSessionId: expect.any(String) });
const id = (registered as { viewerSessionId: string }).viewerSessionId;
expect(
await server.mock.ctx.asUser("user-1", async () => viewers.heartbeat(id)),
).toBe(true);
expect(
await server.mock.ctx.asUser("user-2", async () => viewers.heartbeat(id)),
).toBe(false);
expect(
await server.mock.ctx.asUser("user-1", () => viewers.register(99)),
).toEqual({ skipped: true, reason: "host_not_found" });
});
it("stops every timer when the plugin is disabled", async () => {
server = await startServer();
const jobs = server.mock.scheduled;
expect(jobs.length).toBeGreaterThan(0);
for (const dispose of server.mock.disposals) await dispose();
expect(jobs.every((job) => job.stopped)).toBe(true);
});
it("registers its host import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("host-metrics.hostImportNormalizer"),
).toBe(hostImportNormalizer);
});
});
describe("hostImportNormalizer", () => {
it("reads this plugin's settings from a Termix export", () => {
expect(
hostImportNormalizer({
pluginSettings: {
"host-metrics": { metricsEnabled: false, enabledWidgets: ["cpu"] },
},
}),
).toMatchObject({ metricsEnabled: false, enabledWidgets: ["cpu"] });
});
it("reads the metrics half of an export from before 2.9.0", () => {
expect(
hostImportNormalizer({
statsConfig: {
statusCheckEnabled: false,
metricsEnabled: true,
metricsInterval: 45,
useGlobalMetricsInterval: false,
excludedMounts: ["/snap"],
},
}),
).toMatchObject({
metricsEnabled: true,
metricsInterval: 45,
excludedMounts: ["/snap"],
});
});
it("ignores the old interval while the global one was in use", () => {
expect(
hostImportNormalizer({ statsConfig: { metricsInterval: 45 } }),
).toMatchObject({ metricsInterval: null });
});
it("writes nothing for a row without metrics settings", () => {
expect(hostImportNormalizer({ name: "x" })).toBeNull();
});
});
@@ -1,82 +1,53 @@
import type { Client } from "ssh2";
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import {
connectionLog,
sudoPasswordOf,
supportsMetrics,
isTcpPingEnabled,
parseStatusHostIds,
tcpPingThroughJumpHost,
type MetricsHost,
} from "../../src/backend/helpers.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { setPluginSsh } from "../../src/backend/ssh.js";
const ssh = createFakeContext().ctx.ssh;
describe("supportsMetrics", () => {
beforeAll(() => {
setPluginSsh(createFakeContext().ctx.ssh);
});
afterAll(() => {
setPluginSsh(null);
});
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
supportsMetrics({ connectionType: "ssh", authType: "password" }, ssh),
).toBe(true);
});
it("defaults missing connectionType to ssh", () => {
expect(supportsMetrics({ authType: "key" })).toBe(true);
expect(supportsMetrics({ authType: "key" }, ssh)).toBe(true);
});
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
it("rejects remote desktop only hosts", () => {
expect(supportsMetrics({ connectionType: "rdp" }, ssh)).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" }, ssh)).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" }, ssh)).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
false,
);
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
false,
);
});
});
describe("isTcpPingEnabled", () => {
it("is enabled when status checks are on and tcp ping is not disabled", () => {
it("accepts a remote desktop host that also has ssh on", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
supportsMetrics(
{ connectionType: "rdp", enableSsh: true, authType: "password" },
ssh,
),
).toBe(true);
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
});
it("is disabled when status checks are off", () => {
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
});
it("is disabled when tcp ping is explicitly disabled", () => {
it("rejects ssh hosts that cannot connect unattended", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
supportsMetrics({ connectionType: "ssh", authType: "none" }, ssh),
).toBe(false);
expect(
supportsMetrics({ connectionType: "ssh", authType: "opkssh" }, ssh),
).toBe(false);
});
});
describe("parseStatusHostIds", () => {
it("distinguishes an unrestricted request from an empty host set", () => {
expect(parseStatusHostIds(undefined)).toBeNull();
expect(parseStatusHostIds("")).toEqual(new Set());
});
it("keeps only valid positive host IDs", () => {
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
});
});
describe("createConnectionLog", () => {
it("builds a log entry without id/timestamp", () => {
const entry = createConnectionLog("info", "connection", "Connecting", {
describe("connectionLog", () => {
it("builds a log entry without id or timestamp", () => {
const entry = connectionLog("info", "connection", "Connecting", {
hostId: 1,
});
expect(entry).toEqual({
@@ -85,61 +56,32 @@ describe("createConnectionLog", () => {
message: "Connecting",
details: { hostId: 1 },
});
expect("id" in entry).toBe(false);
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
describe("sudoPasswordOf", () => {
const host = (extra: Partial<MetricsHost>) =>
({
id: 1,
ip: "h",
port: 22,
username: "u",
userId: "o",
...extra,
}) as MetricsHost;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
it("prefers the host's sudo password", () => {
expect(
sudoPasswordOf(
host({ sudoPassword: "a", terminalConfig: { sudoPassword: "b" } }),
),
).toBe("a");
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
it("falls back to the terminal config", () => {
expect(
sudoPasswordOf(host({ terminalConfig: { sudoPassword: "b" } })),
).toBe("b");
expect(sudoPasswordOf(host({}))).toBeUndefined();
});
});
@@ -5,7 +5,7 @@ import {
isMetricCardId,
isManagerCardId,
METRIC_CARD_IDS,
} from "../../../../src/types/host-metrics.js";
} from "../../src/shared/host-metrics.js";
describe("deriveEnabledWidgets", () => {
it("returns only metric ids, in canonical order, deduped", () => {
@@ -1,84 +0,0 @@
/**
* SSH login alerts reach automations.
*
* This used to be POST /internal/login-alert on this plugin's own port,
* guarded by a localhost IP check plus a shared internal token, and the tests
* here covered both of those plus the middleware ordering bug that once made
* every alert 401. A4 replaced the call with an in-process event, so the
* transport, the shared secret and the ordering hazard are all gone; what is
* left to assert is that the handler forwards the right thing and ignores a
* payload it cannot use.
*
* The route module pulls in the whole metrics stack, so this extracts the
* handler from source rather than importing it, the same way the old test did.
*/
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const declaration = ast.statements.find(
(node) =>
ts.isFunctionDeclaration(node) && node.name?.getText(ast) === "onHostLogin",
);
if (!declaration) throw new Error("onHostLogin is missing");
const notify = vi.fn();
const context: Record<string, unknown> = {
notifyAutomationInternalEvent: notify,
exports: {},
};
runInNewContext(
ts.transpileModule(declaration.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
context,
);
const onHostLogin = context.onHostLogin as (payload: {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
}) => void;
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", () => {
onHostLogin({
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
});
it.each([
["no host", { hostId: 0, userId: "owner", sshUser: "a", fromIp: "1.2.3.4" }],
["no user", { hostId: 42, userId: "", sshUser: "a", fromIp: "1.2.3.4" }],
])("ignores an unusable payload (%s)", (_label, payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
expect(notify).not.toHaveBeenCalled();
});
@@ -2,8 +2,11 @@ import { describe, it, expect } from "vitest";
import {
buildSudoCommand,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { parsePlatformProbe } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
parsePlatformProbe,
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidPid,
@@ -15,7 +18,7 @@ import {
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../src/backend/managers/validation.js";
import {
parseServiceList,
buildServiceActionCommand,
@@ -34,11 +37,6 @@ import {
isValidCronSchedule,
buildApplyCrontabCommand,
} from "../../../src/backend/managers/cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
import {
buildIssueCommand,
buildRenewCommand,
@@ -1,41 +0,0 @@
import { describe, it, expect } from "vitest";
import {
toFixedNum,
kibToGiB,
encodePowerShellCommand,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
describe("toFixedNum", () => {
it("rounds to the requested digit count", () => {
expect(toFixedNum(3.14159, 2)).toBe(3.14);
expect(toFixedNum(3.14159, 0)).toBe(3);
expect(toFixedNum(2.5, 0)).toBe(3);
});
it("defaults to 2 digits", () => {
expect(toFixedNum(1.23456)).toBe(1.23);
});
it("returns null for non-finite or non-number input", () => {
expect(toFixedNum(null)).toBeNull();
expect(toFixedNum(undefined)).toBeNull();
expect(toFixedNum(NaN)).toBeNull();
expect(toFixedNum(Infinity)).toBeNull();
});
});
describe("kibToGiB", () => {
it("converts kibibytes to gibibytes", () => {
expect(kibToGiB(1024 * 1024)).toBe(1);
expect(kibToGiB(0)).toBe(0);
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
});
});
describe("encodePowerShellCommand", () => {
it("UTF-16LE base64 encodes the script, decodable back to the source", () => {
const script = "Write-Output 'hello'";
const encoded = encodePowerShellCommand(script);
expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe(script);
});
});
@@ -1,114 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
import {
execElevated,
ElevationError,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, stderr = "", code: number | null = 0) {
return { stdout, stderr, code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("execElevated (no force)", () => {
it("returns the direct result when the command succeeds unprivileged", async () => {
execCommand.mockResolvedValueOnce(result("hello", "", 0));
const r = await execElevated(fakeClient, "echo hello", "pw");
expect(r.usedSudo).toBe(false);
expect(r.stdout).toBe("hello");
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("does NOT escalate when stdout merely contains scary words", async () => {
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
// not should be surfaced as-is, never retried under sudo.
execCommand.mockResolvedValueOnce(
result("log line: permission denied for user foo", "", 1),
);
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
expect(r.usedSudo).toBe(false);
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("escalates when stderr indicates a permission problem", async () => {
execCommand
.mockResolvedValueOnce(result("", "Permission denied", 1))
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
expect(r.usedSudo).toBe(true);
expect(r.stdout).toBe("elevated output");
expect(execCommand).toHaveBeenCalledTimes(2);
});
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
await expect(
execElevated(fakeClient, "cat /etc/shadow", undefined),
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
});
});
describe("execElevated (forced)", () => {
it("strips the success marker from stdout", async () => {
execCommand.mockResolvedValueOnce(
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
);
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
expect(r.stdout).toBe("the real output\n");
expect(r.usedSudo).toBe(true);
});
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
execCommand.mockResolvedValueOnce(
result(
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
"",
0,
),
);
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
forceSudo: true,
});
expect(r.usedSudo).toBe(true);
expect(r.stdout).toContain("incorrect password");
});
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
execCommand.mockResolvedValueOnce(
result("", "sudo: 1 incorrect password attempt", 1),
);
await expect(
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
).rejects.toMatchObject({ code: "SUDO_FAILED" });
});
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
execCommand.mockResolvedValueOnce(
result("", "deploy is not in the sudoers file.", 1),
);
await expect(
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
).rejects.toMatchObject({ code: "NOT_SUDOER" });
});
it("throws SUDO_REQUIRED when forced without a password", async () => {
await expect(
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
).rejects.toBeInstanceOf(ElevationError);
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../../../src/backend/hosts/metrics-shared/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
"Host denied (verification failed)",
"Host key changed - please connect via Terminal to verify the new key",
])("classifies %s", (message) => {
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
});
it("does not classify ordinary authentication failures", () => {
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
false,
);
});
});
describe("host availability status", () => {
it("does not call a TCP-reachable host online before authentication", () => {
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
});
it("keeps a verified host online across later reachability checks", () => {
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./server";
// The four tables as core's SQLite bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE host_metrics_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_host_metrics_prefs_user_host
ON host_metrics_preferences (user_id, host_id);
CREATE TABLE host_health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
checks TEXT NOT NULL,
interval_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_host_health_checks_user_host
ON host_health_checks (user_id, host_id);
CREATE TABLE host_health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
check_id TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ok INTEGER NOT NULL,
latency_ms INTEGER,
detail TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX idx_host_health_history_lookup
ON host_health_history (user_id, host_id, check_id, ts);
CREATE TABLE host_metrics_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
cpu_percent REAL,
mem_percent REAL,
disk_percent REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER
);
CREATE INDEX idx_host_metrics_history_host_ts
ON host_metrics_history (host_id, ts DESC);
`;
const LEGACY = [
"host_metrics_preferences",
"host_health_checks",
"host_health_history",
"host_metrics_history",
];
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexNames = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
const seedLegacy = (sqlite: TestDb["sqlite"]) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO host_metrics_preferences (user_id, host_id, layout)
VALUES ('user-1', 7, '{"slots":[],"columns":3}');
INSERT INTO host_health_checks (user_id, host_id, checks, interval_seconds)
VALUES ('user-1', 7, '[]', 600);
INSERT INTO host_health_history (user_id, host_id, check_id, ts, ok, latency_ms, detail)
VALUES ('user-1', 7, 'web', '2026-09-01 10:00:00', 1, 12, 'HTTP 200');
INSERT INTO host_metrics_history (host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes)
VALUES (7, '2026-09-01 10:00:00', 12.5, 40, 70, 100, 200);
`);
};
describe("adopting the host metrics tables", () => {
it("keeps every row and index when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, { before: seedLegacy });
expect(db.applied).toEqual(["0001_adopt_host_metrics_tables"]);
for (const table of LEGACY) {
expect(tableExists(table)).toBe(false);
expect(tableExists(`p_host_metrics_${table}`)).toBe(true);
}
expect(
db.sqlite
.prepare(
"SELECT user_id, host_id, layout FROM p_host_metrics_host_metrics_preferences",
)
.all(),
).toEqual([
{ user_id: "user-1", host_id: 7, layout: '{"slots":[],"columns":3}' },
]);
expect(
db.sqlite
.prepare(
"SELECT checks, interval_seconds FROM p_host_metrics_host_health_checks",
)
.all(),
).toEqual([{ checks: "[]", interval_seconds: 600 }]);
expect(
db.sqlite
.prepare(
"SELECT check_id, ok, latency_ms, detail FROM p_host_metrics_host_health_history",
)
.all(),
).toEqual([{ check_id: "web", ok: 1, latency_ms: 12, detail: "HTTP 200" }]);
expect(
db.sqlite
.prepare(
"SELECT ts, cpu_percent, net_tx_bytes FROM p_host_metrics_host_metrics_history",
)
.all(),
).toEqual([
{ ts: "2026-09-01 10:00:00", cpu_percent: 12.5, net_tx_bytes: 200 },
]);
expect(indexNames("p_host_metrics_host_metrics_preferences")).toEqual([
"idx_host_metrics_prefs_user_host",
]);
expect(indexNames("p_host_metrics_host_health_checks")).toEqual([
"idx_host_health_checks_user_host",
]);
expect(indexNames("p_host_metrics_host_health_history")).toEqual([
"idx_host_health_history_lookup",
]);
expect(indexNames("p_host_metrics_host_metrics_history")).toEqual([
"idx_host_metrics_history_host_ts",
]);
});
it("creates the tables fresh when there are no legacy ones", async () => {
db = await createTestDb(pluginDir);
for (const table of LEGACY) {
expect(tableExists(table)).toBe(false);
expect(
db.sqlite.prepare(`SELECT * FROM p_host_metrics_${table}`).all(),
).toEqual([]);
}
});
it("cascades every table on host delete, and the user tables on user delete", async () => {
db = await createTestDb(pluginDir, { before: seedLegacy });
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
for (const table of LEGACY.slice(0, 3)) {
expect(
db.sqlite.prepare(`SELECT * FROM p_host_metrics_${table}`).all(),
).toEqual([]);
}
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
expect(
db.sqlite
.prepare("SELECT * FROM p_host_metrics_host_metrics_history")
.all(),
).toEqual([]);
});
});
@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import type { MetricsHost } from "../../src/backend/helpers.js";
const collectMetrics = vi.fn();
vi.mock("../../src/backend/collect.js", async (importOriginal) => ({
...(await importOriginal<object>()),
collectMetrics: (...args: unknown[]) => collectMetrics(...args),
}));
const { MetricsPoller, TOPIC_SNAPSHOT, TOPIC_STATUS } =
await import("../../src/backend/poller.js");
const { createMetricsState } = await import("../../src/backend/state.js");
const { createLogger } = await import("../../src/backend/log.js");
const sample = {
cpu: { percent: 10, cores: 2, load: null },
memory: { percent: 20, usedGiB: 1, totalGiB: 4 },
disk: { percent: 30, filesystems: [] },
network: { interfaces: [{ rxBytes: "100", txBytes: "200" }] },
};
function host(id: number): MetricsHost {
return {
id,
userId: "user-1",
ip: `10.0.0.${id}`,
port: 22,
username: "root",
authType: "password",
connectionType: "ssh",
} as MetricsHost;
}
function setup(hostIds = [7]) {
const fake = createFakeContext({
pluginId: "host-metrics",
actor: "user-1",
sshHosts: hostIds.map(host),
hostStatuses: Object.fromEntries(
hostIds.map((id) => [
id,
{ status: "reachable" as const, lastChecked: "now" },
]),
),
});
const samples: unknown[] = [];
const state = createMetricsState();
const poller = new MetricsPoller({
ctx: fake.ctx,
log: createLogger(fake.ctx.log),
repository: {
recordSample: async (input: unknown) => {
samples.push(input);
},
} as never,
ssh: fake.ctx.ssh,
state,
sessions: {} as never,
collectors: {} as never,
});
return { fake, poller, state, samples };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
beforeEach(() => {
collectMetrics.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("MetricsPoller", () => {
it("polls from the first viewer and reports a working login", async () => {
collectMetrics.mockImplementation(async (_deps, _host, _mounts, onAuth) => {
onAuth?.();
return sample;
});
const { fake, poller, samples } = setup();
poller.registerViewer(7, "viewer-a", "user-1");
await flush();
expect(collectMetrics).toHaveBeenCalledOnce();
expect(poller.getMetrics(7)?.data).toEqual(sample);
expect(fake.statusReports).toEqual([{ hostId: 7, ok: true }]);
expect(samples).toEqual([
expect.objectContaining({
hostId: 7,
cpuPercent: 10,
netRxBytes: 100,
netTxBytes: 200,
}),
]);
expect(fake.emitted.map((event) => event.topic)).toEqual([
TOPIC_STATUS,
TOPIC_SNAPSHOT,
]);
expect(fake.emitted[0].payload).toMatchObject({
hostId: 7,
state: "collecting",
previous: null,
});
const job = fake.scheduled.find((entry) => entry.kind === "every");
expect(job?.ms).toBe(30_000);
});
it("uses the host's own interval over the admin one", async () => {
collectMetrics.mockResolvedValue(sample);
const { fake, poller } = setup();
await fake.ctx.settings.set("metricsInterval", 60);
await fake.ctx.settings.setHost(7, "metricsInterval", 15);
poller.registerViewer(7, "viewer-a", "user-1");
await flush();
expect(fake.scheduled.find((entry) => entry.kind === "every")?.ms).toBe(
15_000,
);
});
it("waits for a host core sees as offline", async () => {
collectMetrics.mockResolvedValue(sample);
const { fake, poller } = setup();
fake.hostStatuses.set(7, { status: "offline", lastChecked: "now" });
poller.registerViewer(7, "viewer-a", "user-1");
await flush();
expect(collectMetrics).not.toHaveBeenCalled();
// Core's check comes back: the first sample runs.
fake.hostStatuses.set(7, { status: "reachable", lastChecked: "now" });
poller.hostStatusChanged(7, "reachable");
await flush();
expect(collectMetrics).toHaveBeenCalledOnce();
});
it("reports a failed login and why collection stopped", async () => {
collectMetrics.mockRejectedValue(
new Error("All configured authentication methods failed"),
);
const { fake, poller } = setup();
poller.registerViewer(7, "viewer-a", "user-1");
await flush();
expect(fake.statusReports).toEqual([
{ hostId: 7, ok: false, hostKeyChanged: false },
]);
expect(fake.emitted.at(-1)?.payload).toMatchObject({
state: "auth_failed",
});
});
it("marks a changed host key", async () => {
collectMetrics.mockRejectedValue(new Error("Host key changed"));
const { fake, poller, state } = setup();
poller.registerViewer(7, "viewer-a", "user-1");
await flush();
expect(fake.statusReports).toEqual([
{ hostId: 7, ok: false, hostKeyChanged: true },
]);
expect(state.authFailures.shouldSkip(7)).toBe(true);
poller.hostKeyAccepted(7);
expect(state.authFailures.shouldSkip(7)).toBe(false);
});
it("stops with the last viewer", async () => {
collectMetrics.mockResolvedValue(sample);
const { fake, poller } = setup();
poller.registerViewer(7, "a", "user-1");
poller.registerViewer(7, "b", "user-1");
await flush();
const job = fake.scheduled.find((entry) => entry.kind === "every")!;
poller.unregisterViewer(7, "a");
expect(job.stopped).toBe(false);
poller.unregisterViewer(7, "b");
expect(job.stopped).toBe(true);
expect(poller.isPolling(7)).toBe(false);
});
it("drops viewers that stop sending heartbeats", async () => {
collectMetrics.mockResolvedValue(sample);
const { poller } = setup();
poller.registerViewer(7, "a", "user-1");
await flush();
poller.cleanupInactiveViewers(Date.now() + 121_000);
expect(poller.hasViewers(7)).toBe(false);
});
it("skips a host with metrics turned off", async () => {
const { fake, poller } = setup();
await fake.ctx.settings.setHost(7, "metricsEnabled", false);
poller.registerViewer(7, "a", "user-1");
await flush();
expect(poller.isPolling(7)).toBe(false);
expect(collectMetrics).not.toHaveBeenCalled();
});
it("grows poll concurrency with the number of polled hosts", async () => {
collectMetrics.mockResolvedValue(sample);
const ids = Array.from({ length: 200 }, (_, index) => index + 1);
const { poller, state } = setup(ids);
expect(state.metricsLimiter.limit).toBe(5);
for (const id of ids) poller.registerViewer(id, `v${id}`, "user-1");
await flush();
expect(state.metricsLimiter.limit).toBe(10);
});
it("drops a pooled connection when a host changes", async () => {
collectMetrics.mockResolvedValue(sample);
const { fake, poller } = setup();
poller.registerViewer(7, "a", "user-1");
await flush();
await poller.hostUpdated(7);
expect(fake.droppedPools).toEqual([{ pool: "stats", hostId: 7 }]);
expect(poller.isPolling(7)).toBe(true);
});
it("forgets a deleted host", async () => {
collectMetrics.mockResolvedValue(sample);
const { poller } = setup();
poller.registerViewer(7, "a", "user-1");
await flush();
poller.hostDeleted(7);
expect(poller.isPolling(7)).toBe(false);
expect(poller.getMetrics(7)).toBeUndefined();
expect(poller.hasViewers(7)).toBe(false);
});
});
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { HostMetricsHistoryRepository } from "../../../../../src/backend/database/repositories/host-metrics-history-repository.js";
describe("HostMetricsHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new HostMetricsHistoryRepository(context, onWrite);
}
it("creates and lists metrics history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -1,119 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { HostMetricsPreferenceRepository } from "../../../../../src/backend/database/repositories/host-metrics-preference-repository.js";
describe("HostMetricsPreferenceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsPreferenceRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_preferences (
user_id, host_id, layout, created_at, updated_at
)
VALUES (
'user-1',
1,
'{"slots":[],"columns":3}',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostMetricsPreferenceRepository(context, onWrite);
}
it("finds a saved layout by user and host", async () => {
const repo = await createRepository();
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
});
it("updates and inserts layouts with write notifications", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertLayout(
"user-1",
1,
'{"slots":[{"id":"cpu"}],"columns":2}',
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: 1,
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertLayout(
"user-2",
2,
'{"slots":[{"id":"mem"}],"columns":1}',
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
layout: '{"slots":[{"id":"mem"}],"columns":1}',
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("updates host stats config for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.updateHostStatsConfig(
"user-1",
1,
'{"enabledWidgets":["cpu","memory"]}',
),
).resolves.toBe(true);
await expect(
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("deletes all metric preferences for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
'{"slots":["mem"]}',
);
expect(writeCount).toBe(2);
});
});
@@ -0,0 +1,242 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./server";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const ROUTES: Array<[string, string, unknown?]> = [
["GET", "/defaults"],
["GET", "/metrics/7"],
["POST", "/metrics/start/7"],
["POST", "/metrics/stop/7", {}],
["POST", "/metrics/connect-totp", { sessionId: "x", totpCode: "1" }],
["POST", "/metrics/heartbeat", { viewerSessionId: "x" }],
["POST", "/metrics/register-viewer", { hostId: 7 }],
["POST", "/metrics/unregister-viewer", { hostId: 7, viewerSessionId: "x" }],
["GET", "/metrics/history/7"],
["GET", "/host-metrics/preferences/7"],
["POST", "/host-metrics/preferences/7", { slots: [], columns: 3 }],
["GET", "/host-metrics/platform/7"],
["POST", "/host-metrics/managers/health/7/config", { checks: [] }],
];
describe("permissions", () => {
it.each(ROUTES)(
"%s %s answers 403 without host-metrics.use",
async (method, path, body) => {
server = await startServer({ permissions: [] });
const response = await server.request(method, path, { body });
expect(response.status).toBe(403);
},
);
});
describe("defaults", () => {
it("tells any user whether new hosts start with metrics on", async () => {
server = await startServer();
expect((await server.request("GET", "/defaults")).body).toEqual({
enabledForNewHosts: true,
});
await server.mock.ctx.settings.set("enabledForNewHosts", false);
expect((await server.request("GET", "/defaults")).body).toEqual({
enabledForNewHosts: false,
});
});
});
describe("metrics", () => {
it("answers 404 until a sample exists", async () => {
server = await startServer();
const response = await server.request("GET", "/metrics/7");
expect(response.status).toBe(404);
expect(response.body.error).toBe("Metrics not available");
});
it("refuses a host the user cannot see", async () => {
server = await startServer();
const response = await server.request("GET", "/metrics/99");
expect(response.status).toBe(403);
});
it("rejects a bad host id", async () => {
server = await startServer();
expect((await server.request("GET", "/metrics/abc")).status).toBe(400);
});
});
describe("viewers", () => {
it("registers, heartbeats and unregisters a viewer", async () => {
server = await startServer();
const registered = await server.request(
"POST",
"/metrics/register-viewer",
{
body: { hostId: 7 },
},
);
expect(registered.status).toBe(200);
const viewerSessionId = registered.body.viewerSessionId as string;
expect(viewerSessionId).toMatch(/^viewer-/);
expect(
(
await server.request("POST", "/metrics/heartbeat", {
body: { viewerSessionId },
})
).status,
).toBe(200);
// Another user cannot keep or drop someone else's viewer.
expect(
(
await server.request("POST", "/metrics/heartbeat", {
user: "user-2",
body: { viewerSessionId },
})
).status,
).toBe(404);
await server.request("POST", "/metrics/unregister-viewer", {
body: { hostId: 7, viewerSessionId },
});
expect(
(
await server.request("POST", "/metrics/heartbeat", {
body: { viewerSessionId },
})
).status,
).toBe(404);
});
it("skips a host with metrics turned off", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(7, "metricsEnabled", false);
const response = await server.request("POST", "/metrics/register-viewer", {
body: { hostId: 7 },
});
expect(response.body).toEqual({
success: true,
skipped: true,
reason: "metrics_disabled",
});
});
it("skips a host it cannot find", async () => {
server = await startServer();
const response = await server.request("POST", "/metrics/register-viewer", {
body: { hostId: 99 },
});
expect(response.body.reason).toBe("host_not_found");
});
});
describe("history", () => {
it("returns stored samples in the range", async () => {
server = await startServer();
const now = new Date();
const ts = now.toISOString().slice(0, 19).replace("T", " ");
server.db.sqlite
.prepare(
"INSERT INTO p_host_metrics_host_metrics_history (host_id, ts, cpu_percent) VALUES (?, ?, ?)",
)
.run(7, ts, 42);
const response = await server.request("GET", "/metrics/history/7?range=1h");
expect(response.status).toBe(200);
expect(response.body.rows).toEqual([
expect.objectContaining({ ts, cpu_percent: 42 }),
]);
});
it("rejects an unknown range", async () => {
server = await startServer();
const response = await server.request("GET", "/metrics/history/7?range=2y");
expect(response.status).toBe(400);
});
});
describe("layout preferences", () => {
it("defaults from the host's enabled widgets", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(7, "enabledWidgets", ["cpu"]);
const response = await server.request("GET", "/host-metrics/preferences/7");
expect(response.body.layout.slots.map((s: { id: string }) => s.id)).toEqual(
["cpu"],
);
});
it("saves a layout and keeps the owner's enabled widgets in step", async () => {
server = await startServer();
const layout = {
columns: 2,
slots: [
{ id: "memory", order: 0, colSpan: 1, height: null },
{ id: "service_manager", order: 1, colSpan: 2, height: null },
],
};
expect(
(
await server.request("POST", "/host-metrics/preferences/7", {
body: layout,
})
).status,
).toBe(200);
const read = await server.request("GET", "/host-metrics/preferences/7");
expect(read.body.layout).toEqual(layout);
expect(await server.mock.ctx.settings.getHost(7, "enabledWidgets")).toEqual(
["memory"],
);
});
it("rejects a malformed layout", async () => {
server = await startServer();
const response = await server.request(
"POST",
"/host-metrics/preferences/7",
{ body: { columns: 2 } },
);
expect(response.status).toBe(400);
});
});
describe("health checks", () => {
it("saves a user's checks for a host", async () => {
server = await startServer();
const checks = [
{ id: "web", name: "Web", type: "tcp", target: "x", port: 80 },
];
const response = await server.request(
"POST",
"/host-metrics/managers/health/7/config",
{ body: { checks, intervalSeconds: 120 } },
);
expect(response.status).toBe(200);
expect(
server.db.sqlite
.prepare(
"SELECT user_id, checks, interval_seconds FROM p_host_metrics_host_health_checks",
)
.all(),
).toEqual([
{
user_id: "user-1",
checks: JSON.stringify(checks),
interval_seconds: 120,
},
]);
});
it("rejects invalid checks", async () => {
server = await startServer();
const response = await server.request(
"POST",
"/host-metrics/managers/health/7/config",
{ body: { checks: [{ id: "x" }] } },
);
expect(response.status).toBe(400);
});
});
@@ -0,0 +1,132 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockContextOptions,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/** A host owned by user-1, as ctx.hosts and ctx.ssh.resolveHost see it. */
export function testHost(id: number, extra: Record<string, unknown> = {}) {
return {
summary: {
id,
userId: "user-1",
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
},
ssh: {
id,
userId: "user-1",
ip: `10.0.0.${id}`,
port: 22,
username: "root",
authType: "password",
connectionType: "ssh",
enableSsh: true,
password: "pw",
...extra,
},
};
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed and the acting user set per request.
*/
export async function startServer(
options: {
permissions?: string[];
hosts?: number[];
mock?: Partial<MockContextOptions>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
const hosts = (options.hosts ?? [7]).map((id) => testHost(id));
for (const host of hosts) {
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(host.ssh.id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["host-metrics.use"],
hosts: hosts.map((host) => host.summary),
sshHosts: hosts.map((host) => host.ssh),
...options.mock,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
for (const dispose of mock.disposals.reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -1,11 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import {
authFailureTracker,
AuthFailureTracker,
canStartInitialMetrics,
ConcurrentLimiter,
createMetricsState,
HostPollCache,
metricsConcurrencyFor,
} from "../../../../../src/backend/hosts/metrics-shared/state.js";
} from "../../src/backend/state.js";
const authFailureTracker = new AuthFailureTracker();
describe("auth failure tracking", () => {
it("permanently pauses polling after a host key mismatch", () => {
@@ -22,13 +25,23 @@ describe("auth failure tracking", () => {
});
describe("initial metrics admission", () => {
it("requires both an active viewer and a confirmed online status", () => {
it("needs a viewer and a host core does not see as offline", () => {
expect(canStartInitialMetrics("online", true)).toBe(true);
expect(canStartInitialMetrics("reachable", true)).toBe(true);
expect(canStartInitialMetrics("offline", true)).toBe(false);
expect(canStartInitialMetrics(undefined, true)).toBe(false);
expect(canStartInitialMetrics("online", false)).toBe(false);
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
// Status checks off: core has no status, so the sample is tried.
expect(canStartInitialMetrics(null, true)).toBe(true);
});
});
describe("createMetricsState", () => {
it("gives every activation its own trackers", () => {
const first = createMetricsState();
const second = createMetricsState();
first.authFailures.recordFailure(1, "AUTH", true);
expect(first.authFailures.shouldSkip(1)).toBe(true);
expect(second.authFailures.shouldSkip(1)).toBe(false);
});
});
@@ -1,36 +0,0 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for Support#1216: routine status polling on a host with
// metrics disabled ran a full SSH authentication attempt on every interval,
// which fires a live 2FA push each cycle on RADIUS/Duo-backed devices with
// no active session. Spinning up the full metrics-service Express app (DB,
// SSH clients, timers, polling managers, etc.) just to drive pollHostStatus
// through two intervals is out of scope, so this asserts directly against
// the source that the status-poll path never calls into SSH auth, the same
// way login-alert-route-order.test.ts guards route registration order.
describe("metrics service status polling", () => {
it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/backend/routes.ts"),
"utf8",
);
const pollHostStatusStart = source.indexOf("private async pollHostStatus(");
const pollHostMetricsStart = source.indexOf(
"private async pollHostMetrics(",
);
expect(pollHostStatusStart).toBeGreaterThan(-1);
expect(pollHostMetricsStart).toBeGreaterThan(pollHostStatusStart);
const pollHostStatusBody = source.slice(
pollHostStatusStart,
pollHostMetricsStart,
);
expect(pollHostStatusBody).not.toContain("withSshConnection");
expect(pollHostStatusBody).not.toContain("statusAfterAuthentication");
});
});