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

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

No files matched your search

@@ -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({
+1 -26
View File
@@ -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");
+100
View File
@@ -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");
});
});