mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -483,7 +483,6 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
statsConfig: JSON.stringify({ cpu: true }),
|
||||
});
|
||||
const second = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostHealthRepository } from "../../../database/repositories/host-health-repository.js";
|
||||
|
||||
describe("HostHealthRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostHealthRepository> {
|
||||
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, 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_health_checks (
|
||||
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-1',
|
||||
1,
|
||||
'[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]',
|
||||
300,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostHealthRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts check configuration", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findChecksByUserAndHost("user-1", 1);
|
||||
expect(existing?.intervalSeconds).toBe(300);
|
||||
|
||||
const updated = await repo.upsertChecks(
|
||||
"user-1",
|
||||
1,
|
||||
'[{"id":"http"}]',
|
||||
60,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: existing?.id,
|
||||
checks: '[{"id":"http"}]',
|
||||
intervalSeconds: 60,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertChecks(
|
||||
"user-2",
|
||||
2,
|
||||
'[{"id":"tcp"}]',
|
||||
120,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
hostId: 2,
|
||||
checks: '[{"id":"tcp"}]',
|
||||
intervalSeconds: 120,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and prunes history", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
1,
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
1,
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const history = await repo.listHistory("user-1", 1, 10);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
checkId: "two",
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "closed",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all health checks and history for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120);
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
10,
|
||||
);
|
||||
await repo.recordHistory(
|
||||
"user-2",
|
||||
2,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
10,
|
||||
);
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
checksDeleted: 1,
|
||||
historyDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
checksDeleted: 0,
|
||||
historyDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(await repo.listHistory("user-1", 1, 10)).toEqual([]);
|
||||
expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2);
|
||||
expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,6 @@ describe("HostRepository Proxmox sync inserts", () => {
|
||||
tunnelConnections: "[]",
|
||||
jumpHosts: null,
|
||||
quickActions: null,
|
||||
statsConfig: null,
|
||||
dockerConfig: null,
|
||||
terminalConfig: null,
|
||||
forceKeyboardInteractive: "false",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
locked: false,
|
||||
accessible: new Set<number>([1, 2]),
|
||||
statuses: new Map<number, { status: string; lastChecked: string }>(),
|
||||
}));
|
||||
|
||||
const service = vi.hoisted(() => ({
|
||||
statusesFor: vi.fn(),
|
||||
get: vi.fn(),
|
||||
check: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
retimeAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => (state.locked ? null : "key") },
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
|
||||
new Set(ids.filter((id) => state.accessible.has(id))),
|
||||
canAccessHost: async (_user: string, id: number) => ({
|
||||
hasAccess: state.accessible.has(id),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../hosts/status/host-status-service.js", () => ({
|
||||
DEFAULT_STATUS_INTERVAL: 60,
|
||||
GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval",
|
||||
hostStatusService: service,
|
||||
}));
|
||||
|
||||
const { registerHostStatusRoutes, parseStatusHostIds } =
|
||||
await import("../../../database/routes/host-status-routes.js");
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let base = "";
|
||||
|
||||
async function start(isAdmin = true) {
|
||||
const router = express.Router();
|
||||
const authenticateJWT: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "user-1";
|
||||
next();
|
||||
};
|
||||
const requireAdmin: RequestHandler = (_req, res, next) =>
|
||||
isAdmin ? next() : void res.status(403).json({ error: "Admin only" });
|
||||
registerHostStatusRoutes(router, { authenticateJWT, requireAdmin });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/host", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
}
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.locked = false;
|
||||
state.accessible = new Set([1, 2]);
|
||||
state.statuses = new Map([
|
||||
[1, { status: "online", lastChecked: "t" }],
|
||||
[2, { status: "offline", lastChecked: "t" }],
|
||||
[3, { status: "reachable", lastChecked: "t" }],
|
||||
]);
|
||||
service.statusesFor.mockImplementation(async () => state.statuses);
|
||||
service.get.mockImplementation(
|
||||
(id: number) => state.statuses.get(id) ?? null,
|
||||
);
|
||||
service.check.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server)
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /host/status", () => {
|
||||
it("only returns hosts the user can reach", async () => {
|
||||
await start();
|
||||
const { status, body } = await call("GET", "/host/status");
|
||||
expect(status).toBe(200);
|
||||
expect(Object.keys(body)).toEqual(["1", "2"]);
|
||||
expect(service.statusesFor).toHaveBeenCalledWith("user-1", null);
|
||||
});
|
||||
|
||||
it("narrows to the requested hosts", async () => {
|
||||
await start();
|
||||
const { body } = await call("GET", "/host/status?hostIds=2");
|
||||
expect(Object.keys(body)).toEqual(["2"]);
|
||||
expect(service.statusesFor).toHaveBeenCalledWith("user-1", new Set([2]));
|
||||
});
|
||||
|
||||
it("answers 401 while the user's data is locked", async () => {
|
||||
await start();
|
||||
state.locked = true;
|
||||
expect((await call("GET", "/host/status")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /host/status/:id", () => {
|
||||
it("returns one host's status", async () => {
|
||||
await start();
|
||||
const { status, body } = await call("GET", "/host/status/1");
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe("online");
|
||||
});
|
||||
|
||||
it("hides a host the user cannot reach", async () => {
|
||||
await start();
|
||||
expect((await call("GET", "/host/status/3")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status settings", () => {
|
||||
it("reads the default interval", async () => {
|
||||
await start();
|
||||
expect((await call("GET", "/host/status/settings")).body).toEqual({
|
||||
statusCheckInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a new interval and re-times the checks", async () => {
|
||||
await start();
|
||||
const { status } = await call("PUT", "/host/status/settings", {
|
||||
statusCheckInterval: 90,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(state.settings.global_status_check_interval).toBe("90");
|
||||
expect(service.retimeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an interval out of range", async () => {
|
||||
await start();
|
||||
expect(
|
||||
(await call("PUT", "/host/status/settings", { statusCheckInterval: 1 }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("is admin only", async () => {
|
||||
await start(false);
|
||||
expect((await call("GET", "/host/status/settings")).status).toBe(403);
|
||||
expect(
|
||||
(await call("PUT", "/host/status/settings", { statusCheckInterval: 90 }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /host/status/refresh", () => {
|
||||
it("restarts the user's checks", async () => {
|
||||
await start();
|
||||
expect((await call("POST", "/host/status/refresh")).status).toBe(200);
|
||||
expect(service.refresh).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStatusHostIds", () => {
|
||||
it("tells 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]));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { WebSocket } from "ws";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||
import { authFailureTracker } from "../../hosts/metrics-shared/state.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
|
||||
const { updateHostKey } = vi.hoisted(() => ({ updateHostKey: vi.fn() }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -12,10 +12,15 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
const hostId = 991267;
|
||||
const accepted: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostKeyUpdated, (payload) =>
|
||||
accepted.push(payload),
|
||||
);
|
||||
afterEach(() => {
|
||||
authFailureTracker.reset(hostId);
|
||||
accepted.length = 0;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
afterAll(() => unsubscribe());
|
||||
|
||||
async function verifyChangedKey(action: "accept" | "reject") {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
@@ -52,35 +57,22 @@ async function verifyChangedKey(action: "accept" | "reject") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("metrics recovery after host key verification", () => {
|
||||
it("allows polling after accepting and saving the replacement key", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
describe("host key recovery", () => {
|
||||
it("announces an accepted and saved replacement key", async () => {
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(updateHostKey).toHaveBeenCalledOnce();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(false);
|
||||
expect(accepted).toEqual([{ hostId }]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked when the new key is rejected", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing when the new key is rejected", async () => {
|
||||
expect(await verifyChangedKey("reject")).toBe(false);
|
||||
expect(updateHostKey).not.toHaveBeenCalled();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked if persisting the accepted key fails", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing if persisting the accepted key fails", async () => {
|
||||
updateHostKey.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
expect(await verifyChangedKey("accept")).toBe(false);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["AUTH", "TOTP"] as const)(
|
||||
"preserves unrelated %s failures",
|
||||
async (reason) => {
|
||||
authFailureTracker.recordFailure(hostId, reason, true);
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -122,7 +122,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
autostartKeyPassword: null,
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
statsConfig: null,
|
||||
terminalConfig: null,
|
||||
socks5ProxyChain: null,
|
||||
quickActions: null,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { HostStatusService, toStatusTarget } =
|
||||
await import("../../../hosts/status/host-status-service.js");
|
||||
const { hostSessionStatus } =
|
||||
await import("../../../hosts/host-session-status.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||
|
||||
type Target = ReturnType<typeof toStatusTarget>;
|
||||
|
||||
function target(id: number, extra: Partial<Target> = {}): Target {
|
||||
return {
|
||||
id,
|
||||
userId: "owner",
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(targets: Target[], reachable = true) {
|
||||
const emitted: unknown[] = [];
|
||||
const ping = vi.fn(async () => reachable);
|
||||
const pingThroughJumpHosts = vi.fn(async () => reachable);
|
||||
const loadTargets = vi.fn(
|
||||
async (filter: { userId?: string; hostIds?: number[] }) =>
|
||||
targets.filter(
|
||||
(t) =>
|
||||
(!filter.userId || t.userId === filter.userId) &&
|
||||
(!filter.hostIds || filter.hostIds.includes(t.id)),
|
||||
),
|
||||
);
|
||||
const service = new HostStatusService({
|
||||
loadTargets,
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let active: InstanceType<typeof HostStatusService> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
active?.stop();
|
||||
active = null;
|
||||
pluginEvents.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HostStatusService", () => {
|
||||
it("starts a user's own hosts and reports them reachable", async () => {
|
||||
const { service, emitted, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
expect(emitted).toContainEqual({
|
||||
hostId: 1,
|
||||
ownerUserId: "owner",
|
||||
status: "reachable",
|
||||
previous: null,
|
||||
online: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports offline for a host that does not answer", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
});
|
||||
|
||||
it("never checks a host with status checks off", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("goes through the jump hosts when a host has them", async () => {
|
||||
const { service, ping, pingThroughJumpHosts } = setup([
|
||||
target(1, { jumpHosts: [{ hostId: 9 }] }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("asks a registered plugin for a protocol's port", async () => {
|
||||
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
|
||||
active = service;
|
||||
service.registerPort("rdp", async () => 3390);
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
|
||||
});
|
||||
|
||||
it("falls back to the host's port when no plugin knows the protocol", async () => {
|
||||
const { service, ping } = setup([
|
||||
target(1, { connectionType: "rdp", port: 3389 }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
|
||||
});
|
||||
|
||||
it("turns reachable into online when a plugin reports a login", async () => {
|
||||
const { service, emitted } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
service.reportLogin(1, { ok: true });
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: "online",
|
||||
previous: "reachable",
|
||||
});
|
||||
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
expect(service.get(1)).toMatchObject({
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the host key reason once the new key is accepted", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
|
||||
expect(service.get(1)?.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows a host with an open terminal session as online", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
const release = hostSessionStatus.register(1);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
release();
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
});
|
||||
|
||||
it("only checks the requested hosts on the desktop app", async () => {
|
||||
const { service, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", new Set([2]));
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
|
||||
});
|
||||
|
||||
it("checks again on its interval", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(15_000);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks a host on demand unless the last result is fresh", async () => {
|
||||
const { service, ping } = setup([target(1)]);
|
||||
active = service;
|
||||
expect(await service.check(1)).toMatchObject({ status: "reachable" });
|
||||
expect(await service.check(1)).toMatchObject({ status: "reachable" });
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a deleted host", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("never logs in to check a host", async () => {
|
||||
// The only transports are the two ping functions: nothing here can
|
||||
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
|
||||
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
|
||||
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStatusTarget", () => {
|
||||
it("parses the jump hosts and defaults the connection type", () => {
|
||||
expect(
|
||||
toStatusTarget({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "",
|
||||
jumpHosts: '[{"hostId":3},{"bad":1}]',
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/status/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,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
let peak = 0;
|
||||
let current = 0;
|
||||
|
||||
const job = async () => {
|
||||
current += 1;
|
||||
peak = Math.max(peak, current);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
current -= 1;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
]);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("runs waiters in FIFO order after a slot frees", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
const order: number[] = [];
|
||||
|
||||
const first = limiter.run(async () => {
|
||||
order.push(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
const third = limiter.run(async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
|
||||
describe("setLimit", () => {
|
||||
it("releases queued waiters as soon as the ceiling is raised", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.pendingCount).toBe(3);
|
||||
|
||||
// Widening must drain the backlog without waiting for the running job.
|
||||
limiter.setLimit(4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not over-release beyond the new ceiling", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
// Later waves of woken jobs enqueue their own resolvers, so draining has
|
||||
// to keep going until nothing is left rather than flushing a snapshot.
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
limiter.setLimit(3);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(3);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lets running work finish when the ceiling shrinks", async () => {
|
||||
const limiter = new ConcurrentLimiter(4);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
|
||||
// Shrinking never kills in-flight work; it applies to later releases.
|
||||
limiter.setLimit(2);
|
||||
expect(limiter.activeCount).toBe(4);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
// The two queued jobs ran only after the shrink, so they never pushed
|
||||
// occupancy back up to the old width.
|
||||
expect(peak).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects an invalid new limit", () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
|
||||
expect(limiter.limit).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
|
||||
|
||||
describe("tcpPingThroughJumpHost", () => {
|
||||
it("reports the final destination online when forwarding succeeds", async () => {
|
||||
const stream = { destroy: vi.fn() };
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
|
||||
expect(host).toBe("private.example");
|
||||
expect(port).toBe(22);
|
||||
callback(undefined, stream);
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(true);
|
||||
expect(stream.destroy).toHaveBeenCalledOnce();
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding fails", async () => {
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
|
||||
callback(new Error("Connection refused"));
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
const result = tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
"private.example",
|
||||
22,
|
||||
5000,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,9 @@ const h = vi.hoisted(() => ({
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -111,6 +114,22 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
}),
|
||||
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
|
||||
h.statusReports.push({ hostId, ...outcome }),
|
||||
registerPort: (connectionType: string) => {
|
||||
h.statusPorts.push(connectionType);
|
||||
return () => h.statusPortsRemoved.push(connectionType);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
@@ -419,3 +438,69 @@ describe("ctx.hosts session tracking and activity", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.status", () => {
|
||||
it("reads and checks core's status with hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(hosts.status.get(4)).toEqual({
|
||||
status: "reachable",
|
||||
lastChecked: "t",
|
||||
});
|
||||
expect(hosts.status.get(5)).toBeNull();
|
||||
await expect(hosts.status.check(5)).resolves.toEqual({
|
||||
status: "offline",
|
||||
lastChecked: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every status member without hosts:read", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.status.get(4)).toThrow(PluginCapabilityError);
|
||||
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes login reports on to core", () => {
|
||||
h.statusReports = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
h.statusPorts = [];
|
||||
h.statusPortsRemoved = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const bag = new DisposableBag("fixture");
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.registerPort("rdp", () => 3389);
|
||||
expect(h.statusPorts).toEqual(["rdp"]);
|
||||
await bag.disposeAll();
|
||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ const h = vi.hoisted(() => ({
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
cleared: [] as string[],
|
||||
clearedAll: 0,
|
||||
pooled: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
@@ -27,7 +30,33 @@ vi.mock("../../plugins/permissions.js", async () => {
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => {
|
||||
h.pooled.push(key);
|
||||
return fn(await factory());
|
||||
},
|
||||
connectionPool: {
|
||||
clearKeyConnections: (key: string) => h.cleared.push(key),
|
||||
clearAllConnections: () => {
|
||||
h.clearedAll += 1;
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
resolveConnectHost: async (target: unknown) =>
|
||||
typeof target === "number"
|
||||
? {
|
||||
id: target,
|
||||
userId: "user-1",
|
||||
ip: `10.0.0.${target}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
}
|
||||
: target,
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
@@ -71,6 +100,9 @@ beforeEach(() => {
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
h.cleared = [];
|
||||
h.clearedAll = 0;
|
||||
h.pooled = [];
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
@@ -170,6 +202,62 @@ describe("ctx.ssh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh pooled connections", () => {
|
||||
const both = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function plugin(id: string) {
|
||||
const bag = new DisposableBag(id);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: { ...(manifest(both) as object), id } as never,
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
return { bag, ssh };
|
||||
}
|
||||
|
||||
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
const other = plugin("fleets");
|
||||
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
|
||||
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
|
||||
await metrics.bag.disposeAll();
|
||||
|
||||
expect(h.clearedAll).toBe(0);
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
await expect(
|
||||
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
|
||||
).resolves.toBe("still");
|
||||
});
|
||||
|
||||
it("dropPooled clears only this plugin's keys for that host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
|
||||
|
||||
metrics.ssh.dropPooled("stats", 7);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
|
||||
// Dropped keys are forgotten, so deactivate only clears what is left.
|
||||
h.cleared = [];
|
||||
await metrics.bag.disposeAll();
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
|
||||
});
|
||||
|
||||
it("dropPooled ignores another pool and an unknown host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
metrics.ssh.dropPooled("other", 7);
|
||||
metrics.ssh.dropPooled("stats", 99);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
|
||||
@@ -96,9 +96,8 @@ describe("plugin event bus", () => {
|
||||
it("exposes the internal topic names the server publishes", () => {
|
||||
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
|
||||
expect(TOPICS.internalEvent).toBe("internal.event");
|
||||
expect(TOPICS.hostMetrics).toBe("host.metrics");
|
||||
expect(TOPICS.hostStatus).toBe("host.status");
|
||||
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
|
||||
expect(TOPICS.hostKeyUpdated).toBe("host.key.updated");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,30 +134,6 @@ describe("hosts publishers route through the bus", () => {
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
|
||||
const { notifyAutomationStatus, notifyAutomationMetrics } =
|
||||
await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js");
|
||||
|
||||
const status = vi.fn();
|
||||
const metrics = vi.fn();
|
||||
pluginEvents.on(TOPICS.hostStatus, status);
|
||||
pluginEvents.on(TOPICS.hostMetrics, metrics);
|
||||
|
||||
notifyAutomationStatus(42, "user-1", true);
|
||||
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
online: true,
|
||||
});
|
||||
expect(metrics).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
metrics: { cpu: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
|
||||
const { HostSessionStatus } =
|
||||
await import("../../hosts/host-session-status.js");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log };
|
||||
});
|
||||
|
||||
const { createPluginSchedule } = await import("../../plugins/schedule.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.schedule", () => {
|
||||
it("repeats a job and stops it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const job = vi.fn();
|
||||
|
||||
const stop = schedule.every(1000, job);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
|
||||
it("runs straight away when asked", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
const job = vi.fn();
|
||||
schedule.every(1000, job, { runNow: true });
|
||||
expect(job).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never overlaps a slow run with the next tick", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
schedule.every(100, async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
running--;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it("logs a failing job instead of throwing", async () => {
|
||||
const log = vi.fn();
|
||||
const schedule = createPluginSchedule(new DisposableBag("fixture"), log);
|
||||
schedule.every(100, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(log).toHaveBeenCalledWith("Scheduled job failed", expect.any(Error));
|
||||
});
|
||||
|
||||
it("clears every timer when the plugin is disposed", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const repeating = vi.fn();
|
||||
const once = vi.fn();
|
||||
schedule.every(100, repeating, { jitterMs: 50 });
|
||||
schedule.after(500, once);
|
||||
|
||||
await bag.disposeAll();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(repeating).not.toHaveBeenCalled();
|
||||
expect(once).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a one-off job once and can cancel it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const once = vi.fn();
|
||||
schedule.after(100, once);
|
||||
const cancelled = vi.fn();
|
||||
const cancel = schedule.after(100, cancelled);
|
||||
cancel();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(once).toHaveBeenCalledOnce();
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Host Metrics' settings leaving core: the two admin keys, the new-host
|
||||
* default inside host_defaults, and the metrics half of every host's
|
||||
* stats_config. Nothing may be lost, and a second run must change nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "host-metrics" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
|
||||
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
|
||||
);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
describe("hostSettingsFromStatsConfig", () => {
|
||||
it("keeps only what differs from the defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify({
|
||||
statusCheckEnabled: false,
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
useGlobalMetricsInterval: false,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the host's interval while it followed the global one", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a value that was stringified twice", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
|
||||
),
|
||||
).toEqual({ metricsEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostMetricsSettingsMigration", () => {
|
||||
it("moves the admin keys, the new-host default and every host", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
state.coreSettings.set("metrics_history_retention_days", "30");
|
||||
state.coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
|
||||
);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
|
||||
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(4, null);
|
||||
|
||||
const result = await runHostMetricsSettingsMigration();
|
||||
|
||||
expect(result.moved.sort()).toEqual([
|
||||
"global_metrics_interval",
|
||||
"host_defaults.metricsEnabled",
|
||||
"metrics_history_retention_days",
|
||||
]);
|
||||
expect(value("admin", null, "metricsInterval")).toBe(20);
|
||||
expect(value("admin", null, "historyRetentionDays")).toBe(30);
|
||||
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(false);
|
||||
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
|
||||
// Only the status half, which is core's: nothing for the plugin.
|
||||
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
await runHostMetricsSettingsMigration();
|
||||
|
||||
// Changed through the plugin after the upgrade.
|
||||
const interval = find("admin", null, "metricsInterval")!;
|
||||
interval.value = "90";
|
||||
const enabled = find("host", "1", "metricsEnabled")!;
|
||||
enabled.value = "true";
|
||||
|
||||
const second = await runHostMetricsSettingsMigration();
|
||||
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
|
||||
expect(value("admin", null, "metricsInterval")).toBe(90);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
expect(await runHostMetricsSettingsMigration()).toEqual({
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips values out of range", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "1");
|
||||
state.coreSettings.set("metrics_history_retention_days", "900");
|
||||
await runHostMetricsSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The status half of ssh_data.stats_config moving into the core
|
||||
* status_check_enabled and status_check_interval columns. Run against a real
|
||||
* SQLite database through the same raw SQL helpers the boot path uses.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
runHostStatusConfigMigration,
|
||||
statusConfigFromStatsConfig,
|
||||
HOST_STATUS_CONFIG_MIGRATED,
|
||||
} =
|
||||
await import("../../utils/crypto-migration/host-status-config-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function createHosts(withStatsConfig = true) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
${withStatsConfig ? "stats_config TEXT," : ""}
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
const hosts = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings.clear();
|
||||
createHosts();
|
||||
});
|
||||
|
||||
describe("statusConfigFromStatsConfig", () => {
|
||||
it.each([
|
||||
[null, true, null],
|
||||
["{}", true, null],
|
||||
['{"statusCheckEnabled":false}', false, null],
|
||||
['{"disableTcpPing":true}', false, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
|
||||
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
|
||||
[
|
||||
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
|
||||
false,
|
||||
null,
|
||||
],
|
||||
["not json", true, null],
|
||||
])("%s", (raw, enabled, interval) => {
|
||||
expect(statusConfigFromStatsConfig(raw)).toEqual({
|
||||
statusCheckEnabled: enabled,
|
||||
statusCheckInterval: interval,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostStatusConfigMigration", () => {
|
||||
it("copies each host's status options into the new columns", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(
|
||||
2,
|
||||
JSON.stringify({
|
||||
useGlobalStatusInterval: false,
|
||||
statusCheckInterval: 45,
|
||||
}),
|
||||
);
|
||||
insert.run(
|
||||
3,
|
||||
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
|
||||
);
|
||||
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
|
||||
insert.run(5, null);
|
||||
|
||||
const result = await runHostStatusConfigMigration();
|
||||
|
||||
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
|
||||
expect(hosts()).toEqual([
|
||||
{ id: 1, enabled: 0, interval: null },
|
||||
{ id: 2, enabled: 1, interval: 45 },
|
||||
{ id: 3, enabled: 0, interval: null },
|
||||
{ id: 4, enabled: 1, interval: null },
|
||||
{ id: 5, enabled: 1, interval: null },
|
||||
]);
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
// The user turns checks back on after the upgrade.
|
||||
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: true,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
|
||||
});
|
||||
|
||||
it("finishes on a fresh install that never had stats_config", async () => {
|
||||
createHosts(false);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: false,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user