mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
-80
@@ -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);
|
||||
});
|
||||
});
|
||||
-119
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
+18
-5
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user