feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompt } from "../../../../../plugins/ai/backend/context.js";
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
describe("buildSystemPrompt", () => {
it("tells the assistant to stay inside what was asked", () => {
// Without these the assistant answered "what is running on this server"
// by also proposing a monitoring script and an automation nobody wanted.
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("nothing beyond it");
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
expect(prompt).toMatch(/no monitoring automations, no scripts/);
expect(prompt).toMatch(/One request means one proposal at most/);
});
it("states that it proposes rather than applies", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("cannot change anything directly");
expect(prompt).toContain("Never claim you have done something");
});
it("never claims credential access", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toMatch(/no access to passwords, SSH keys, API keys/);
});
it("mentions read-only commands only when the user opted in", () => {
expect(buildSystemPrompt(BASE)).not.toMatch(/read-only diagnostic/);
expect(buildSystemPrompt({ ...BASE, allowReadOnlyCommands: true })).toMatch(
/read-only diagnostic/,
);
});
it("pluralises the host count", () => {
expect(buildSystemPrompt({ ...BASE, hostCount: 1 })).toContain("1 host ");
expect(buildSystemPrompt({ ...BASE, hostCount: 2 })).toContain("2 hosts");
});
it("includes the active tab only when there is one", () => {
expect(buildSystemPrompt(BASE)).not.toContain("currently looking at");
expect(buildSystemPrompt({ ...BASE, activeTab: "terminal" })).toContain(
"currently looking at: terminal",
);
});
});
-122
View File
@@ -1,122 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PRIVATE_ALLOWLIST,
evaluateEgress,
isPrivateDestination,
parseAllowlist,
} from "../../../../../plugins/ai/backend/egress.js";
/**
* The rule that lets a self-hosted Ollama work without turning the backend
* into an authenticated probe of its own network: private destinations are
* refused unless an admin named the host.
*/
describe("isPrivateDestination", () => {
it("recognises loopback and private ranges", () => {
for (const url of [
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://10.0.0.5:11434",
"http://192.168.1.10:11434",
"http://172.16.4.4:11434",
"http://169.254.169.254/latest/meta-data",
"http://[::1]:11434",
]) {
expect(isPrivateDestination(url), url).toBe(true);
}
});
it("treats public hosts as public", () => {
for (const url of [
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://generativelanguage.googleapis.com",
"http://8.8.8.8",
]) {
expect(isPrivateDestination(url), url).toBe(false);
}
});
});
describe("evaluateEgress", () => {
it("allows public destinations without an allowlist entry", () => {
const decision = evaluateEgress("https://api.openai.com/v1", []);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(false);
});
it("refuses a private destination that is not allowlisted", () => {
const decision = evaluateEgress("http://192.168.1.50:11434", ["localhost"]);
expect(decision.allowed).toBe(false);
expect(decision.isPrivate).toBe(true);
expect(decision.reason).toContain("allowlist");
});
it("allows a private destination once its host is allowlisted", () => {
const decision = evaluateEgress("http://localhost:11434", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(true);
});
it.each(["llm.internal", "host.docker.internal"])(
"routes allowlisted private DNS name %s through the private path",
(host) => {
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
allowed: true,
isPrivate: true,
});
},
);
it("matches the allowlist case-insensitively", () => {
expect(
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
).toBe(true);
});
it("does not let one allowlisted host authorise another", () => {
// A cloud metadata endpoint is the classic target, so an allowlist for
// localhost must not open 169.254.169.254.
const decision = evaluateEgress("http://169.254.169.254/latest/meta-data", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(false);
});
it("refuses non-http protocols and embedded credentials", () => {
expect(evaluateEgress("file:///etc/passwd", []).allowed).toBe(false);
expect(evaluateEgress("ftp://example.com", []).allowed).toBe(false);
expect(evaluateEgress("https://user:pass@api.openai.com", []).allowed).toBe(
false,
);
});
it("refuses a malformed url", () => {
expect(evaluateEgress("not a url", []).allowed).toBe(false);
});
});
describe("parseAllowlist", () => {
it("falls back to the defaults when unset or malformed", () => {
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
});
it("normalises stored entries", () => {
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
"localhost",
"10.0.0.5",
]);
});
it("honours a deliberately empty allowlist", () => {
// An admin clearing the list must actually block everything private,
// not silently get the defaults back.
expect(parseAllowlist("[]")).toEqual([]);
});
});
-209
View File
@@ -1,209 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../../../../plugins/ai/backend/providers/types.js";
const streamChat = vi.fn();
const handler = vi.fn();
vi.mock("../../../../../plugins/ai/backend/providers/registry.js", () => ({
getAdapter: () => ({ streamChat, listModels: async () => [] }),
}));
vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({
getTool: (name: string) =>
name === "list_hosts"
? {
name: "list_hosts",
description: "List hosts",
category: "read",
parameters: { type: "object", properties: {} },
handler,
}
: undefined,
toolDefinitions: () => [
{ name: "list_hosts", description: "List hosts", parameters: {} },
],
}));
const { runAgent } =
await import("../../../../../plugins/ai/backend/engine.js");
function chunks(...values: ChatChunk[]) {
return (async function* () {
for (const value of values) yield value;
})();
}
const BASE = {
config: { providerType: "ollama" as const },
model: "test",
system: "system",
context: {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: false,
},
};
async function collect(history: any[] = []) {
const events: any[] = [];
for await (const event of runAgent({ ...BASE, history })) {
events.push(event);
}
return events;
}
describe("runAgent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("streams text and finishes when no tools are called", async () => {
streamChat.mockReturnValueOnce(
chunks({ type: "text", text: "hello" }, { type: "done" }),
);
const events = await collect();
expect(events.filter((e) => e.type === "token")).toHaveLength(1);
expect(events.at(-1).type).toBe("done");
});
it("runs a known tool and feeds the result back", async () => {
handler.mockResolvedValue({ hosts: [{ id: 1, name: "web-1" }] });
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
expect(handler).toHaveBeenCalledOnce();
expect(events.some((e) => e.type === "tool_result")).toBe(true);
expect(streamChat).toHaveBeenCalledTimes(2);
});
it("refuses a tool that is not in the catalog", async () => {
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "read_credentials", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
// A model can emit any name it likes; only the catalog decides what runs.
expect(handler).not.toHaveBeenCalled();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("Unknown tool");
});
it("surfaces a handler failure without ending the run", async () => {
handler.mockRejectedValue(new Error("database is down"));
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("database is down");
expect(events.at(-1).type).toBe("done");
});
it("closes the tool call when a tool returns a proposal", async () => {
// Without a matching tool_result the call rendered as permanently
// running, even though the work was done and awaiting the user.
handler.mockResolvedValue({
__proposal: true,
kind: "propose_create_host",
summary: "Add host web-1",
payload: {},
});
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const callIndex = events.findIndex((e) => e.type === "tool_call");
const resultIndex = events.findIndex((e) => e.type === "tool_result");
const proposalIndex = events.findIndex((e) => e.type === "proposal");
expect(resultIndex).toBeGreaterThan(callIndex);
expect(proposalIndex).toBeGreaterThan(resultIndex);
expect(events[resultIndex]).toMatchObject({
name: "list_hosts",
result: { status: "awaiting_user_approval" },
});
});
it("reports a provider failure as an error and stops", async () => {
streamChat.mockImplementationOnce(() => {
throw new Error("provider unreachable");
});
const events = await collect();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: "provider unreachable",
});
});
it("stops after too many tool turns", async () => {
handler.mockResolvedValue({ ok: true });
streamChat.mockImplementation(() =>
chunks(
{
type: "tool_call",
call: { id: "c", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
);
const events = await collect();
// A model that never stops calling tools must not spin forever.
expect(events.at(-1)).toMatchObject({ type: "error" });
expect(streamChat.mock.calls.length).toBeLessThanOrEqual(8);
});
});
@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingsRepository = { getBoolean: vi.fn() };
const userPreferenceRepository = { findByUserId: vi.fn() };
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => settingsRepository,
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
}));
const { isAiGloballyEnabled, resolveAiAccess } =
await import("../../../../../plugins/ai/backend/gating.js");
describe("AI gating", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults to off so upgrading an install enables nothing", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
await isAiGloballyEnabled();
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
"ai_globally_enabled",
false,
);
});
it("blocks everyone when the admin global is off", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(false);
expect(access.allowReadOnlyCommands).toBe(false);
// The kill switch short-circuits, so the preference is never consulted.
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
});
it("blocks a user who has not enabled it", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: false,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats never-asked as not enabled", async () => {
// Null means the user was never shown the choice, which is not consent.
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: null,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats a missing preference row as not enabled", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue(null);
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("allows only when both gates are open", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
expect(access.allowReadOnlyCommands).toBe(true);
});
it("keeps read-only commands off unless separately opted in", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: null,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
expect(access.allowReadOnlyCommands).toBe(false);
});
});
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithProxy = vi.fn();
const getFetchDispatcher = vi.fn();
const safeOutboundFetch = vi.fn();
const readPrivateAllowlist = vi.fn();
const evaluateEgress = vi.fn();
const globalFetch = vi.fn();
vi.mock("../../../../utils/proxy-agent.js", () => ({
fetchWithProxy,
getFetchDispatcher,
}));
vi.mock("../../../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch,
}));
vi.mock("../../../../../../plugins/ai/backend/egress.js", () => ({
readPrivateAllowlist,
evaluateEgress,
}));
const { providerFetch } =
await import("../../../../../../plugins/ai/backend/providers/http.js");
describe("providerFetch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", globalFetch);
readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]);
});
it("uses the matching Undici fetch implementation for private providers", async () => {
const response = { ok: true, status: 200 } as Response;
const init = { method: "GET" };
evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true });
fetchWithProxy.mockResolvedValue(response);
await expect(
providerFetch("http://192.168.1.50:11434/api/tags", init),
).resolves.toBe(response);
expect(fetchWithProxy).toHaveBeenCalledWith(
"http://192.168.1.50:11434/api/tags",
init,
);
expect(globalFetch).not.toHaveBeenCalled();
expect(safeOutboundFetch).not.toHaveBeenCalled();
});
});
@@ -1,308 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../../../../../plugins/ai/backend/providers/types.js";
const providerFetch = vi.fn();
vi.mock("../../../../../../plugins/ai/backend/providers/http.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../../../plugins/ai/backend/providers/http.js")
>("../../../../../../plugins/ai/backend/providers/http.js");
return { ...actual, providerFetch };
});
const { openAiAdapter } =
await import("../../../../../../plugins/ai/backend/providers/openai.js");
const { ollamaAdapter } =
await import("../../../../../../plugins/ai/backend/providers/ollama.js");
const { geminiAdapter } =
await import("../../../../../../plugins/ai/backend/providers/gemini.js");
/** Builds a Response whose body streams the given text chunks. */
function streamingResponse(lines: string[]): Response {
const encoder = new TextEncoder();
return {
ok: true,
status: 200,
body: {
getReader() {
let index = 0;
return {
async read() {
if (index >= lines.length) return { done: true, value: undefined };
return { done: false, value: encoder.encode(lines[index++]) };
},
releaseLock() {},
};
},
},
} as unknown as Response;
}
/** One SSE frame, built from an object so the JSON stays readable. */
function sseFrame(payload: unknown): string {
return `data: ${JSON.stringify(payload)}\n`;
}
async function collect(iterable: AsyncIterable<ChatChunk>) {
const chunks: ChatChunk[] = [];
for await (const chunk of iterable) chunks.push(chunk);
return chunks;
}
const REQUEST = {
model: "test-model",
system: "system",
messages: [{ role: "user" as const, content: "hi" }],
tools: [],
};
describe("openAiAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("normalises streamed text", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n',
'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
expect(chunks.filter((c) => c.type === "text")).toEqual([
{ type: "text", text: "Hel" },
{ type: "text", text: "lo" },
]);
expect(chunks.at(-1)?.type).toBe("done");
});
it("reassembles tool arguments split across deltas", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{\\"a"}}]}}]}\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":1}"}}]}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
const call = chunks.find((c) => c.type === "tool_call");
expect(call).toMatchObject({
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: { a: 1 } },
});
});
it("survives malformed tool arguments", async () => {
// A model that emits broken JSON gets an empty object; the tool's own
// validation then reports the problem back to it.
providerFetch.mockResolvedValue(
streamingResponse([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{not json"}}]}}]}\n',
"data: [DONE]\n",
]),
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
);
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
call: { arguments: {} },
});
});
it("needs a base url for an openai-compatible provider", async () => {
await expect(
collect(
openAiAdapter.streamChat(
{ providerType: "openai_compatible" },
REQUEST,
),
),
).rejects.toThrow("base URL");
});
});
describe("ollamaAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("reads newline-delimited json rather than sse", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'{"message":{"content":"Hel"}}\n',
'{"message":{"content":"lo"}}\n',
'{"done":true,"done_reason":"stop"}\n',
]),
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
);
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
expect(chunks.at(-1)).toMatchObject({ type: "done", stopReason: "stop" });
});
it("accepts tool arguments as an object or a json string", async () => {
providerFetch.mockResolvedValue(
streamingResponse([
'{"message":{"tool_calls":[{"function":{"name":"list_hosts","arguments":{"a":1}}}]}}\n',
'{"message":{"tool_calls":[{"function":{"name":"get_host","arguments":"{\\"hostId\\":2}"}}]}}\n',
'{"done":true}\n',
]),
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
);
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
expect(calls[0].call.arguments).toEqual({ a: 1 });
expect(calls[1].call.arguments).toEqual({ hostId: 2 });
});
});
describe("geminiAdapter", () => {
beforeEach(() => vi.clearAllMocks());
it("carries thoughtSignature off a function call", async () => {
// Gemini 2.5+ 400s a follow-up whose functionCall parts lost their
// signature, which broke every conversation on the second turn.
providerFetch.mockResolvedValue(
streamingResponse([
sseFrame({
candidates: [
{
content: {
parts: [
{
functionCall: { name: "list_hosts", args: {} },
thoughtSignature: "sig-abc",
},
],
},
},
],
}),
]),
);
const chunks = await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
REQUEST,
),
);
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
call: { name: "list_hosts", providerSignature: "sig-abc" },
});
});
it("echoes the signature back on the next turn", async () => {
providerFetch.mockResolvedValue(streamingResponse([]));
await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
{
...REQUEST,
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "c1",
name: "list_hosts",
arguments: {},
providerSignature: "sig-abc",
},
],
},
{
role: "tool",
content: "{}",
toolCallId: "c1",
toolName: "list_hosts",
},
],
},
),
);
const body = JSON.parse(providerFetch.mock.calls[0][1].body as string);
const modelTurn = body.contents.find((c: any) => c.role === "model");
expect(modelTurn.parts[0].thoughtSignature).toBe("sig-abc");
});
});
describe("assertOk error messages", () => {
beforeEach(() => vi.clearAllMocks());
function errorResponse(status: number, body: string): Response {
return {
ok: false,
status,
text: async () => body,
} as unknown as Response;
}
it("pulls the message out of a nested error body", async () => {
// Slicing the raw JSON used to cut the text off mid-sentence.
providerFetch.mockResolvedValue(
errorResponse(
400,
JSON.stringify({
error: {
code: 400,
message: "Function call is missing a signature.",
},
}),
),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow("Function call is missing a signature.");
});
it("explains a rate limit instead of dumping the body", async () => {
providerFetch.mockResolvedValue(
errorResponse(
429,
JSON.stringify({ error: { message: "You exceeded your quota." } }),
),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow(/rate limit reached/i);
});
it("explains a rejected key", async () => {
providerFetch.mockResolvedValue(
errorResponse(401, JSON.stringify({ error: { message: "Bad key" } })),
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow(/rejected the API key/i);
});
it("falls back to a trimmed snippet for a non-JSON body", async () => {
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
).rejects.toThrow("upstream exploded");
});
});
@@ -1,80 +0,0 @@
import { describe, expect, it } from "vitest";
import {
REDACTED,
redact,
redactString,
} from "../../../../../plugins/ai/backend/redaction.js";
describe("redact", () => {
it("drops secret-named fields at any depth", () => {
const input = {
name: "web-1",
password: "hunter2",
nested: { privateKey: "abc", apiKey: "def", port: 22 },
list: [{ keyPassword: "xyz", label: "ok" }],
};
const output = redact(input) as any;
expect(output.name).toBe("web-1");
expect(output.password).toBe(REDACTED);
expect(output.nested.privateKey).toBe(REDACTED);
expect(output.nested.apiKey).toBe(REDACTED);
expect(output.nested.port).toBe(22);
expect(output.list[0].keyPassword).toBe(REDACTED);
expect(output.list[0].label).toBe("ok");
});
it("keeps a null secret null so absence stays distinguishable", () => {
const output = redact({ password: null }) as any;
expect(output.password).toBeNull();
});
it("leaves ordinary values untouched", () => {
const input = { id: 4, enabled: true, tags: ["a", "b"], note: null };
expect(redact(input)).toEqual(input);
});
it("does not recurse forever on a cyclic object", () => {
const cyclic: Record<string, unknown> = { name: "loop" };
cyclic.self = cyclic;
expect(() => redact(cyclic)).not.toThrow();
});
});
describe("redactString", () => {
it("masks private key blocks", () => {
const text =
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
expect(redactString(text)).toBe("[redacted private key]");
});
it("masks provider api keys", () => {
expect(redactString("key is sk-abcdefghijklmnopqrst here")).toContain(
"[redacted api key]",
);
expect(redactString("key is sk-ant-abcdefghijklmnopqrst here")).toContain(
"[redacted api key]",
);
});
it("masks bearer tokens and jwts", () => {
expect(
redactString("Authorization: Bearer abcdefghijklmnopqrst"),
).toContain("Bearer [redacted]");
expect(
redactString("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh"),
).toContain("[redacted token]");
});
it("masks Termix api keys", () => {
expect(redactString("tmx_abcdefghijklmnopqrstuvwx")).toContain(
"[redacted token]",
);
});
it("leaves ordinary prose alone", () => {
const text = "The disk on web-1 is 82 percent full.";
expect(redactString(text)).toBe(text);
});
});
@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import { isReadOnlyCommand } from "../../../../../../plugins/ai/backend/tools/command-allowlist.js";
/**
* These commands can run without a per-command approval click, so the parser
* has to refuse anything that could become a second command. Substring
* matching would pass "df; rm -rf /", which is the whole reason this is
* argv-based.
*/
describe("isReadOnlyCommand", () => {
it("allows plain diagnostics", () => {
for (const command of [
"df -h",
"uptime",
"free -m",
"whoami",
"ps aux",
"lsblk",
"uname -a",
"/usr/bin/df -h",
]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(true);
}
});
it("rejects command chaining and redirection", () => {
for (const command of [
"df; rm -rf /",
"df && curl evil.example",
"df || reboot",
"df | sh",
"df > /etc/passwd",
"df >> /etc/passwd",
"cat < /etc/shadow",
"echo `whoami`",
"echo $(id)",
"df\nrm -rf /",
"df \\\n rm",
]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("rejects privilege escalation", () => {
for (const command of ["sudo df -h", "su root", "doas df", "env df"]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("rejects commands that are not on the list", () => {
for (const command of ["rm -rf /", "curl evil.example", "vi /etc/passwd"]) {
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
}
});
it("limits systemctl and docker to read-only subcommands", () => {
expect(isReadOnlyCommand("systemctl status nginx").allowed).toBe(true);
expect(isReadOnlyCommand("systemctl restart nginx").allowed).toBe(false);
expect(isReadOnlyCommand("systemctl stop nginx").allowed).toBe(false);
expect(isReadOnlyCommand("docker ps").allowed).toBe(true);
expect(isReadOnlyCommand("docker stats --no-stream").allowed).toBe(true);
expect(isReadOnlyCommand("docker rm -f web").allowed).toBe(false);
expect(isReadOnlyCommand("docker exec -it web sh").allowed).toBe(false);
});
it("limits cat to safe paths", () => {
expect(isReadOnlyCommand("cat /proc/meminfo").allowed).toBe(true);
expect(isReadOnlyCommand("cat /etc/os-release").allowed).toBe(true);
expect(isReadOnlyCommand("cat /etc/shadow").allowed).toBe(false);
expect(isReadOnlyCommand("cat ~/.ssh/id_rsa").allowed).toBe(false);
expect(isReadOnlyCommand("cat").allowed).toBe(false);
});
it("rejects an empty command", () => {
expect(isReadOnlyCommand("").allowed).toBe(false);
expect(isReadOnlyCommand(" ").allowed).toBe(false);
});
});
@@ -1,254 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hostRepository = {
create: vi.fn(),
findByIdForUser: vi.fn(),
updateForUser: vi.fn(),
deleteForUser: vi.fn(),
};
const snippetRepository = {
createSnippet: vi.fn(),
findOwnedById: vi.fn(),
updateSnippet: vi.fn(),
deleteSnippet: vi.fn(),
};
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
const automationRepository = { create: vi.fn() };
vi.mock("../../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
createCurrentFleetRepository: () => fleetRepository,
createCurrentAutomationRepository: () => automationRepository,
}));
const resolveHostById = vi.fn();
vi.mock("../../../../hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
vi.mock("../../../../hosts/ssh-client-factory.js", () => ({
createFleetSshFactory: () => () => ({}),
getFleetPoolKey: () => "pool",
}));
vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
_factory: unknown,
run: (client: unknown) => Promise<unknown>,
) => run({}),
}));
const { applyProposal } =
await import("../../../../../../plugins/ai/backend/tools/executor.js");
describe("applyProposal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refuses a kind that is not a real tool", async () => {
// The stored payload is treated as untrusted even though the server wrote
// it, because a proposal can outlive the release that created it.
await expect(
applyProposal("propose_delete_everything", {}, "user-1"),
).rejects.toThrow("Unknown proposal kind");
});
it("validates an automation definition before creating it", async () => {
// Reuses the automations route's own validator, so a definition the model
// invented is held to the same standard as a hand-written one.
await expect(
applyProposal(
"propose_create_automation",
{
name: "bad",
definition: { trigger: { kind: "nonsense" }, steps: [] },
},
"user-1",
),
).rejects.toThrow();
expect(automationRepository.create).not.toHaveBeenCalled();
});
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
const result = await applyProposal(
"propose_create_automation",
{
name: "nightly",
definition: {
trigger: { kind: "schedule", intervalSeconds: 3600 },
steps: [{ id: "s1", type: "wait", seconds: 1 }],
},
},
"user-1",
);
expect(result.ok).toBe(true);
expect(automationRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", enabled: false }),
);
});
it("runs an approved command only on a host the user can reach", async () => {
// resolveHostById returns null when the connect-level permission check
// fails, so an unreachable host never gets as far as an SSH attempt.
resolveHostById.mockResolvedValue(null);
await expect(
applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
),
).rejects.toThrow("Host not found");
expect(execCommand).not.toHaveBeenCalled();
});
it("returns command output on success", async () => {
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
const result = await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
);
expect(result.ok).toBe(true);
expect(result.summary).toContain("up 3 days");
});
it("resolves the host rather than using a raw repository row", async () => {
// A raw row has no decrypted auth and an unresolved jumpHosts field, which
// made the SSH factory fail with a jump host error on hosts that have none.
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 });
await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
);
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
expect(hostRepository.findByIdForUser).not.toHaveBeenCalled();
});
it("surfaces a non-zero exit rather than reporting success", async () => {
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 });
await expect(
applyProposal(
"propose_run_command",
{ hostId: 9, command: "cat /etc/shadow" },
"user-1",
),
).rejects.toThrow("code 1");
});
it("creates a host through the normal repository", async () => {
hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" });
const result = await applyProposal(
"propose_create_host",
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] },
"user-1",
);
expect(result.ok).toBe(true);
expect(hostRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
name: "web-1",
ip: "10.0.0.5",
}),
);
});
it("rejects a host payload missing required fields", async () => {
await expect(
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
).rejects.toThrow("ip is required");
expect(hostRepository.create).not.toHaveBeenCalled();
});
it("scopes an update to the approving user", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
hostRepository.updateForUser.mockResolvedValue({ id: 7 });
await applyProposal(
"propose_update_host",
{ hostId: 7, changes: { name: "renamed" } },
"user-1",
);
expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7);
expect(hostRepository.updateForUser).toHaveBeenCalledWith(
"user-1",
7,
expect.objectContaining({ name: "renamed" }),
);
});
it("refuses to update a host the user does not own", async () => {
hostRepository.findByIdForUser.mockResolvedValue(null);
await expect(
applyProposal(
"propose_update_host",
{ hostId: 999, changes: { name: "x" } },
"user-1",
),
).rejects.toThrow("Host not found");
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
});
it("rejects a non-numeric id rather than coercing it", async () => {
await expect(
applyProposal(
"propose_update_host",
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
"user-1",
),
).rejects.toThrow("hostId must be a positive integer");
});
it("only adds fleet members the approving user owns", async () => {
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
hostRepository.findByIdForUser.mockImplementation(
async (_userId: string, hostId: number) =>
hostId === 1 ? { id: 1 } : null,
);
const result = await applyProposal(
"propose_create_fleet",
{ name: "prod", hostIds: [1, 2] },
"user-1",
);
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
expect(result.summary).toContain("1 host");
});
it("reports nothing to change on an empty update", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
const result = await applyProposal(
"propose_update_host",
{ hostId: 7, changes: {} },
"user-1",
);
expect(result.ok).toBe(false);
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
});
});
@@ -1,112 +0,0 @@
import { describe, expect, it } from "vitest";
import {
AI_TOOLS,
FORBIDDEN_DOMAINS,
getTool,
listToolNames,
toolDefinitions,
} from "../../../../../../plugins/ai/backend/tools/catalog.js";
/**
* The security regression test for the whole feature.
*
* Tools run in-process and never pass through the RBAC-gated routers, so "the
* assistant cannot reach credentials or user administration" is a property of
* this catalog, and nothing else. If a future change adds a tool that touches a
* forbidden domain, this test is what catches it.
*/
describe("AI tool catalog", () => {
it("exposes no tool naming a forbidden domain", () => {
for (const tool of AI_TOOLS) {
for (const domain of FORBIDDEN_DOMAINS) {
expect(
tool.name.includes(domain),
`${tool.name} references the forbidden domain "${domain}"`,
).toBe(false);
}
}
});
it("has no tool that could read a credential", () => {
const banned = [
"get_credential",
"list_credentials",
"get_password",
"get_private_key",
"get_api_key",
"create_user",
"delete_user",
"grant_permission",
"update_settings",
];
for (const name of banned) {
expect(getTool(name), `${name} must not exist`).toBeUndefined();
}
});
it("only allows read or propose categories", () => {
for (const tool of AI_TOOLS) {
expect(["read", "propose"]).toContain(tool.category);
}
});
it("names every tool by its category", () => {
// A propose tool that does not say "propose" would read as a direct action
// in the transcript, which is exactly the confusion this feature avoids.
for (const tool of AI_TOOLS) {
if (tool.category === "propose") {
expect(
tool.name.startsWith("propose_"),
`${tool.name} is a propose tool but is not named propose_*`,
).toBe(true);
} else {
expect(
tool.name.startsWith("propose_"),
`${tool.name} is a read tool but is named propose_*`,
).toBe(false);
}
}
});
it("has unique tool names", () => {
const names = listToolNames();
expect(new Set(names).size).toBe(names.length);
});
it("gives every tool a described object schema", () => {
for (const definition of toolDefinitions()) {
expect(definition.description.length, definition.name).toBeGreaterThan(
20,
);
expect(definition.parameters.type, definition.name).toBe("object");
// additionalProperties:false keeps a model from smuggling extra fields
// past the handler's explicit reads.
expect(definition.parameters.additionalProperties, definition.name).toBe(
false,
);
}
});
it("never takes a userId from the model", () => {
// Ownership is always derived from the verified JWT. A userId parameter
// would let the model ask for another account's data.
for (const tool of AI_TOOLS) {
const properties = (tool.parameters.properties ?? {}) as Record<
string,
unknown
>;
for (const key of Object.keys(properties)) {
expect(
/^user_?id$/i.test(key),
`${tool.name} accepts a model-supplied ${key}`,
).toBe(false);
}
}
});
it("still offers the read tools the assistant needs to be useful", () => {
for (const name of ["list_hosts", "list_snippets", "list_automations"]) {
expect(getTool(name), name).toBeDefined();
}
});
});
@@ -1,192 +0,0 @@
import { describe, expect, it } from "vitest";
import {
compare,
extractMetricValue,
hasDwelled,
isCoolingDown,
metricStateKey,
severityForValue,
type MetricsSnapshot,
} from "../../../../../plugins/automations/backend/conditions.js";
const metrics: MetricsSnapshot = {
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
memory: { percent: 61, usedGiB: 7.5 },
disk: {
percent: 30,
filesystems: [
{ mount: "/", percent: 30, availableBytes: 100 },
{ mount: "/data", percent: 93.4, availableBytes: 25 },
],
},
network: {
interfaces: [
{ name: "eth0", rxBytes: "1000", txBytes: "2000" },
{ name: "eth1", rxBytes: "50", txBytes: "60" },
],
},
temperature: { highestCelsius: 71 },
uptime: { seconds: 86400 },
processes: { total: 210 },
};
describe("extractMetricValue", () => {
it("reads simple scalar paths", () => {
expect(extractMetricValue(metrics, { path: "cpu.percent" })).toBe(42.5);
expect(extractMetricValue(metrics, { path: "memory.percent" })).toBe(61);
expect(
extractMetricValue(metrics, { path: "temperature.highestCelsius" }),
).toBe(71);
expect(extractMetricValue(metrics, { path: "uptime.seconds" })).toBe(86400);
expect(extractMetricValue(metrics, { path: "processes.total" })).toBe(210);
});
it("reads load averages positionally", () => {
expect(extractMetricValue(metrics, { path: "cpu.load1" })).toBe(1.5);
expect(extractMetricValue(metrics, { path: "cpu.load15" })).toBe(0.9);
});
it("reads a specific mount rather than the aggregate", () => {
// The motivating case: /data is nearly full while / is fine.
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/data" }),
).toBe(93.4);
expect(extractMetricValue(metrics, { path: "disk.percent" })).toBe(30);
});
it("returns null for a mount that is not present", () => {
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/nope" }),
).toBeNull();
});
it("selects a named interface and coerces string counters", () => {
expect(
extractMetricValue(metrics, { path: "network.rxBytes", iface: "eth1" }),
).toBe(50);
expect(extractMetricValue(metrics, { path: "network.rxBytes" })).toBe(1000);
});
it("extracts per-interface network rates for bandwidth alerts", () => {
const rateMetrics = {
network: {
interfaces: [
{ name: "eth0", rxRateBps: 1024, txRateBps: 2048 },
{ name: "eth1", rxRateBps: 4096, txRateBps: 8192 },
],
},
};
expect(
extractMetricValue(rateMetrics, {
path: "network.rxRateBps",
iface: "eth1",
}),
).toBe(4096);
expect(
extractMetricValue(rateMetrics, {
path: "network.txRateBps",
iface: "eth0",
}),
).toBe(2048);
});
it("returns null for missing metrics rather than throwing", () => {
expect(extractMetricValue(null, { path: "cpu.percent" })).toBeNull();
expect(extractMetricValue({}, { path: "cpu.percent" })).toBeNull();
expect(
extractMetricValue({ cpu: { percent: null } }, { path: "cpu.percent" }),
).toBeNull();
});
});
describe("metricStateKey", () => {
it("scopes state per mount so dwell tracks one filesystem", () => {
expect(metricStateKey(7, { path: "disk.percent" })).toBe("7");
expect(metricStateKey(7, { path: "disk.percent", mount: "/data" })).toBe(
"7:/data",
);
expect(metricStateKey(7, { path: "network.rxBytes", iface: "eth1" })).toBe(
"7:eth1",
);
});
});
describe("compare", () => {
it("handles numeric operators", () => {
expect(compare(93, ">", 90)).toBe(true);
expect(compare(90, ">", 90)).toBe(false);
expect(compare(90, ">=", 90)).toBe(true);
expect(compare(10, "<", 90)).toBe(true);
expect(compare(90, "<=", 90)).toBe(true);
});
it("compares numeric strings numerically", () => {
expect(compare("93", ">", "90")).toBe(true);
// Lexically "9" > "10", so this would be wrong as a string compare.
expect(compare("9", "<", "10")).toBe(true);
});
it("falls back to string equality for non-numeric values", () => {
expect(compare("running", "==", "running")).toBe(true);
expect(compare("running", "!=", "exited")).toBe(true);
});
it("handles containment", () => {
expect(compare("disk full", "contains", "full")).toBe(true);
expect(compare("disk full", "not_contains", "full")).toBe(false);
expect(compare("all good", "not_contains", "error")).toBe(true);
});
it("treats changed as inequality of the rendered values", () => {
expect(compare("online", "changed", "offline")).toBe(true);
expect(compare("online", "changed", "online")).toBe(false);
});
it("is false when a numeric comparison has a non-numeric side", () => {
expect(compare("abc", ">", 5)).toBe(false);
});
});
describe("isCoolingDown", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("is false when nothing has fired yet", () => {
expect(isCoolingDown(null, 15, now)).toBe(false);
});
it("is true inside the window and false outside it", () => {
expect(isCoolingDown("2026-01-01T11:50:00.000Z", 15, now)).toBe(true);
expect(isCoolingDown("2026-01-01T11:40:00.000Z", 15, now)).toBe(false);
});
it("treats a zero cooldown as always ready", () => {
expect(isCoolingDown("2026-01-01T11:59:59.000Z", 0, now)).toBe(false);
});
});
describe("hasDwelled", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("fires immediately when no window is configured", () => {
expect(hasDwelled(null, undefined, now)).toBe(true);
expect(hasDwelled(null, 0, now)).toBe(true);
});
it("requires the window to have elapsed", () => {
expect(hasDwelled("2026-01-01T11:49:00.000Z", 600, now)).toBe(true);
expect(hasDwelled("2026-01-01T11:55:00.000Z", 600, now)).toBe(false);
});
it("is false when a window is set but no breach is open", () => {
expect(hasDwelled(null, 600, now)).toBe(false);
});
});
describe("severityForValue", () => {
it("escalates at 95 and honours an explicit override", () => {
expect(severityForValue(96)).toBe("critical");
expect(severityForValue(90)).toBe("warning");
expect(severityForValue(96, "info")).toBe("info");
});
});
@@ -1,182 +0,0 @@
import { describe, expect, it } from "vitest";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
nextCronRun,
parseCron,
} from "../../../../../plugins/automations/backend/cron.js";
describe("parseCron", () => {
it("rejects anything that is not five fields", () => {
expect(() => parseCron("* * * *")).toThrow(/five fields/);
expect(() => parseCron("* * * * * *")).toThrow(/five fields/);
});
it("expands wildcards, lists, ranges and steps", () => {
const fields = parseCron("0,30 9-17 * * 1-5");
expect([...fields.minutes]).toEqual([0, 30]);
expect([...fields.hours]).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
expect([...fields.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
expect(fields.dowRestricted).toBe(true);
expect(fields.domRestricted).toBe(false);
});
it("supports step syntax", () => {
expect([...parseCron("*/15 * * * *").minutes]).toEqual([0, 15, 30, 45]);
});
it("accepts month and day names", () => {
expect([...parseCron("0 0 1 jan *").months]).toEqual([1]);
expect([...parseCron("0 0 * * sun").daysOfWeek]).toEqual([0]);
});
it("treats day 7 as Sunday", () => {
expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
});
it("rejects out of range values", () => {
expect(() => parseCron("60 * * * *")).toThrow(/out of range/);
expect(() => parseCron("* 24 * * *")).toThrow(/out of range/);
expect(() => parseCron("* * 0 * *")).toThrow(/out of range/);
});
it("reports validity without throwing", () => {
expect(isValidCron("*/5 * * * *")).toBe(true);
expect(isValidCron("nonsense")).toBe(false);
});
});
describe("nextCronRun", () => {
it("finds the next matching minute", () => {
const from = new Date(2026, 0, 1, 10, 3, 30);
expect(nextCronRun("*/15 * * * *", from)).toEqual(
new Date(2026, 0, 1, 10, 15, 0, 0),
);
});
it("never returns the starting minute", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
expect(nextCronRun("0 * * * *", from)).toEqual(
new Date(2026, 0, 1, 11, 0, 0, 0),
);
});
it("rolls into the next day", () => {
const from = new Date(2026, 0, 1, 23, 45, 0);
expect(nextCronRun("0 2 * * *", from)).toEqual(
new Date(2026, 0, 2, 2, 0, 0, 0),
);
});
it("unions day-of-month and day-of-week when both are set", () => {
// The 15th, or any Monday.
const from = new Date(2026, 0, 1, 0, 0, 0);
const next = nextCronRun("0 0 15 * 1", from);
expect(next).not.toBeNull();
const isFifteenth = next!.getDate() === 15;
const isMonday = next!.getDay() === 1;
expect(isFifteenth || isMonday).toBe(true);
});
it("gives up on a date that can never match", () => {
expect(nextCronRun("0 0 30 2 *", new Date(2026, 0, 1))).toBeNull();
});
});
describe("computeNextDueAt", () => {
it("prefers an interval over a cron expression", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 300, cron: "0 0 * * *" }, from),
).toBe("2026-01-01T00:05:00.000Z");
});
it("falls back to cron", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const due = computeNextDueAt({ cron: "30 10 * * *" }, from);
expect(due).toBe(new Date(2026, 0, 1, 10, 30, 0, 0).toISOString());
});
it("returns null when nothing is scheduled", () => {
expect(computeNextDueAt({})).toBeNull();
});
it("passes the zone through to the cron evaluation", () => {
// 02:00 in Tokyo on 2026-06-02 is 17:00 UTC on 2026-06-01.
const from = new Date("2026-06-01T00:00:00.000Z");
expect(
computeNextDueAt({ cron: "0 2 * * *", timezone: "Asia/Tokyo" }, from),
).toBe("2026-06-01T17:00:00.000Z");
});
it("ignores the zone for interval schedules", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 600, timezone: "Asia/Tokyo" }, from),
).toBe("2026-01-01T00:10:00.000Z");
});
});
describe("time zone handling", () => {
it("resolves a daily cron against the given zone", () => {
const from = new Date("2026-06-01T00:00:00.000Z");
// 09:30 New York in June (UTC-4) is 13:30 UTC.
const next = nextCronRun("30 9 * * *", from, "America/New_York");
expect(next?.toISOString()).toBe("2026-06-01T13:30:00.000Z");
});
it("tracks daylight saving, so the UTC instant shifts by an hour", () => {
const summer = nextCronRun(
"0 12 * * *",
new Date("2026-07-01T00:00:00.000Z"),
"America/New_York",
);
const winter = nextCronRun(
"0 12 * * *",
new Date("2026-01-01T00:00:00.000Z"),
"America/New_York",
);
// Noon local both times, but UTC-4 in July and UTC-5 in January.
expect(summer?.toISOString()).toBe("2026-07-01T16:00:00.000Z");
expect(winter?.toISOString()).toBe("2026-01-01T17:00:00.000Z");
});
it("matches the day of week in the target zone, not the server's", () => {
// 23:00 UTC Sunday is already Monday in Tokyo.
const next = nextCronRun(
"0 8 * * mon",
new Date("2026-06-07T22:00:00.000Z"),
"Asia/Tokyo",
);
expect(next?.toISOString()).toBe("2026-06-07T23:00:00.000Z");
});
it("falls back to server time for an unknown zone instead of throwing", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const next = nextCronRun("30 10 * * *", from, "Not/AZone");
expect(next).toEqual(new Date(2026, 0, 1, 10, 30, 0, 0));
});
it("handles midnight, which some hour cycles format as 24", () => {
const next = nextCronRun(
"0 0 * * *",
new Date("2026-06-01T10:00:00.000Z"),
"UTC",
);
expect(next?.toISOString()).toBe("2026-06-02T00:00:00.000Z");
});
});
describe("isValidTimezone", () => {
it("accepts real zones", () => {
expect(isValidTimezone("UTC")).toBe(true);
expect(isValidTimezone("Europe/London")).toBe(true);
});
it("rejects made-up ones", () => {
expect(isValidTimezone("Middle/Earth")).toBe(false);
expect(isValidTimezone("")).toBe(false);
});
});
@@ -1,108 +0,0 @@
import { describe, expect, it } from "vitest";
import {
diffContainerStates,
parseContainerStates,
} from "../../../../../plugins/automations/backend/docker-watcher.js";
/**
* The polling side needs SSH, so what is tested here is the pure part: turning
* `docker ps` output into states, and turning two snapshots into events.
*/
describe("parseContainerStates", () => {
it("reads name, state and health out of the ps output", () => {
const output = [
'{"name":"web","state":"running","status":"Up 2 hours"}',
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}',
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
].join("\n");
const states = parseContainerStates(output);
expect(states.get("web")).toEqual({ state: "running", unhealthy: false });
expect(states.get("db")).toEqual({ state: "exited", unhealthy: false });
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
});
it("skips blank and malformed lines rather than failing the poll", () => {
const output = [
'{"name":"web","state":"running","status":"Up"}',
"",
"not json at all",
'{"state":"running"}',
].join("\n");
const states = parseContainerStates(output);
expect([...states.keys()]).toEqual(["web"]);
});
it("returns nothing for empty output", () => {
expect(parseContainerStates("").size).toBe(0);
});
});
describe("diffContainerStates", () => {
const running = { state: "running", unhealthy: false };
const exited = { state: "exited", unhealthy: false };
it("reports a container that stopped", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", exited]]),
);
expect(events).toEqual([{ container: "web", event: "exited" }]);
});
it("reports a container that started", () => {
const events = diffContainerStates(
new Map([["web", exited]]),
new Map([["web", running]]),
);
expect(events).toEqual([{ container: "web", event: "started" }]);
});
it("reports a container that began restarting", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", { state: "restarting", unhealthy: false }]]),
);
expect(events).toEqual([{ container: "web", event: "restarting" }]);
});
it("reports a container that went unhealthy while still running", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", { state: "running", unhealthy: true }]]),
);
expect(events).toEqual([{ container: "web", event: "unhealthy" }]);
});
it("stays quiet when nothing changed", () => {
const events = diffContainerStates(
new Map([["web", running]]),
new Map([["web", running]]),
);
expect(events).toEqual([]);
});
it("does not re-announce a container that is still unhealthy", () => {
const unhealthy = { state: "running", unhealthy: true };
const events = diffContainerStates(
new Map([["web", unhealthy]]),
new Map([["web", unhealthy]]),
);
expect(events).toEqual([]);
});
// A first sighting is a baseline, not an event: otherwise every container
// running at boot would report itself as freshly started.
it("treats a newly seen container as a baseline", () => {
const events = diffContainerStates(new Map(), new Map([["web", running]]));
expect(events).toEqual([]);
});
it("ignores a container that disappeared", () => {
const events = diffContainerStates(new Map([["web", running]]), new Map());
expect(events).toEqual([]);
});
});
@@ -1,606 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Step,
} from "../../../../types/automations.js";
/**
* The engine reaches the database through the repository factory and the
* outside world through the step executors, so both are mocked here. What is
* under test is the run loop itself: ordering, branching, error policy,
* concurrency, recursion and dry-run.
*/
interface FakeAutomation {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
const automations = new Map<number, FakeAutomation>();
const runs: Array<Record<string, unknown>> = [];
const runSteps: Array<Record<string, unknown>> = [];
let nextRunId = 1;
let nextStepRowId = 1;
const repository = {
findById: vi.fn(async (id: number) => automations.get(id) ?? null),
createRun: vi.fn(async (input: Record<string, unknown>) => {
const run = { id: nextRunId++, ...input };
runs.push(run);
return run;
}),
finishRun: vi.fn(async (runId: number, input: Record<string, unknown>) => {
const run = runs.find((entry) => entry.id === runId);
if (run) Object.assign(run, input);
}),
createRunStep: vi.fn(async (input: Record<string, unknown>) => {
const id = nextStepRowId++;
runSteps.push({ id, ...input });
return id;
}),
finishRunStep: vi.fn(async (id: number, input: Record<string, unknown>) => {
const step = runSteps.find((entry) => entry.id === id);
if (step) Object.assign(step, input);
}),
};
const resolveHostById = vi.fn();
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const executeStep = vi.fn();
vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({
executeStep: (...args: unknown[]) => executeStep(...args),
}));
const { AutomationEngine } =
await import("../../../../../plugins/automations/backend/engine.js");
function defineAutomation(
steps: Step[],
overrides: Partial<FakeAutomation> = {},
): FakeAutomation {
const definition: AutomationDefinition = {
version: 1,
trigger: { kind: "webhook", tokenHash: "x" },
steps,
};
const automation: FakeAutomation = {
id: overrides.id ?? 1,
userId: "user-1",
name: "Test",
enabled: true,
definition: JSON.stringify(definition),
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
...overrides,
};
automations.set(automation.id, automation);
return automation;
}
function step(partial: Partial<Step> & { id: string; type: string }): Step {
return partial as Step;
}
beforeEach(() => {
automations.clear();
runs.length = 0;
runSteps.length = 0;
nextRunId = 1;
nextStepRowId = 1;
vi.clearAllMocks();
resolveHostById.mockResolvedValue(null);
executeStep.mockResolvedValue({ success: true, output: "ok" });
// The singleton carries in-flight state between tests.
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
});
describe("AutomationEngine.run", () => {
it("adds the trigger host name to the template context", async () => {
defineAutomation([step({ id: "notify", type: "notify" })]);
resolveHostById.mockResolvedValue({
name: "Proxmox Node",
ip: "10.0.0.11",
username: "root",
port: 22,
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "metric_threshold",
triggerHostId: 11,
triggerContext: { hostId: 11, value: 97 },
});
const context = executeStep.mock.calls[0][1] as {
template: {
host: { id: number; name: string };
trigger: { hostName: string };
};
};
expect(context.template.host).toMatchObject({
id: 11,
name: "Proxmox Node",
});
expect(context.template.trigger.hostName).toBe("Proxmox Node");
});
it("runs steps in order and records each one", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
expect(runSteps.map((s) => s.stepId)).toEqual(["a", "b"]);
expect(runSteps.map((s) => s.stepIndex)).toEqual([0, 1]);
});
it("fails the run and stops when a step fails under the default policy", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toBe("boom");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("keeps going when a step is marked continue-on-error", async () => {
defineAutomation([
step({ id: "a", type: "run_command", onError: "continue" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
});
it("skips disabled steps", async () => {
defineAutomation([
step({ id: "a", type: "run_command", enabled: false }),
step({ id: "b", type: "http" }),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["b"]);
});
it("passes earlier step output to later steps", async () => {
defineAutomation([
step({ id: "first", type: "run_command" }),
step({ id: "second", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const secondCallContext = executeStep.mock.calls[1][1] as {
template: { steps: Record<string, { stdout: string }> };
};
expect(secondCallContext.template.steps.first.stdout).toBe("hello");
});
it("merges variables set by a step into the template context", async () => {
defineAutomation([
step({ id: "setter", type: "set_var" }),
step({ id: "next", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x = 1",
vars: { x: "1" },
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[1][1] as {
template: { vars: Record<string, string> };
};
expect(context.template.vars.x).toBe("1");
});
describe("if branching", () => {
it("runs the then branch when the condition matches", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "93", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("runs the else branch when it does not", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "10", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "no"]);
});
it("resolves templates on both sides of the condition", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: {
left: "{{trigger.value}}",
operator: ">=",
right: "90",
},
then: [step({ id: "yes", type: "http" })],
}),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "metric_threshold",
triggerContext: { value: 95 },
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("treats an empty else as a no-op", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "1", operator: "==", right: "2" },
then: [step({ id: "yes", type: "http" })],
}),
step({ id: "after", type: "http" }),
]);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "after"]);
});
});
it("halts the run when a step returns a stop signal", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "success" },
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("marks the run failed when a stop step asks for failure", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "failed" },
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
});
describe("recursion protection", () => {
it("refuses to re-enter an automation already in the chain", async () => {
defineAutomation([
step({ id: "nested", type: "run_automation", automationId: 1 }),
]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
// The nested call is refused, and the refusal is recorded on the step.
const nestedStep = runSteps.find((s) => s.stepId === "nested");
expect(nestedStep?.status).toBe("failed");
expect(String(nestedStep?.error)).toMatch(
/already running in this chain/,
);
expect(outcome.status).toBe("failed");
});
it("refuses a mutual cycle between two automations", async () => {
defineAutomation(
[step({ id: "toB", type: "run_automation", automationId: 2 })],
{ id: 1 },
);
defineAutomation(
[step({ id: "toA", type: "run_automation", automationId: 1 })],
{ id: 2 },
);
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const inner = runSteps.find((s) => s.stepId === "toA");
expect(inner?.status).toBe("failed");
expect(String(inner?.error)).toMatch(/already running in this chain/);
});
it("refuses to nest deeper than the maximum depth", async () => {
defineAutomation([step({ id: "a", type: "http" })]);
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
depth: 99,
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/depth/);
expect(runs).toHaveLength(0);
});
});
describe("concurrency", () => {
it("records a skipped run rather than dropping it silently", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
let release: () => void = () => {};
executeStep.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ success: true, output: "done" });
}),
);
const engine = AutomationEngine.getInstance();
const first = engine.run({ automationId: 1, triggerType: "manual" });
// Let the first run register itself as in flight.
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(second.status).toBe("skipped");
release();
await first;
const skipped = runs.find((run) => run.status === "skipped");
expect(skipped).toBeDefined();
expect(String(skipped?.error)).toMatch(/still in progress/);
});
it("skips the second of two triggers that arrive in the same tick", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
executeStep.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ success: true, output: "done" }), 20),
),
);
// No gap between the two: the in-flight slot used to be claimed several
// awaits after it was checked, so both runs got through.
const engine = AutomationEngine.getInstance();
const [first, second] = await Promise.all([
engine.run({ automationId: 1, triggerType: "manual" }),
engine.run({ automationId: 1, triggerType: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual([
"skipped",
"success",
]);
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("releases the in-flight slot when the run row cannot be created", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
repository.createRun.mockRejectedValueOnce(new Error("db down"));
const engine = AutomationEngine.getInstance();
const failed = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(failed.status).toBe("failed");
// A leaked slot would make every later run skip forever.
const after = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(after.status).toBe("success");
});
});
it("propagates the dry-run flag to executors", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("lets a caller force a dry run on a live automation", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
dryRun: true,
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("fails cleanly when the automation is missing", async () => {
const outcome = await AutomationEngine.getInstance().run({
automationId: 404,
triggerType: "manual",
});
expect(outcome).toMatchObject({ status: "failed", runId: null });
});
it("fails cleanly when the definition is not valid JSON", async () => {
automations.set(1, {
id: 1,
userId: "user-1",
name: "Broken",
enabled: true,
definition: "not json",
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
});
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/not valid JSON/);
});
it("turns a thrown executor error into a failed step", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockRejectedValueOnce(new Error("connection reset"));
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(runSteps[0].status).toBe("failed");
expect(String(runSteps[0].error)).toMatch(/connection reset/);
});
it("truncates very large step output", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x".repeat(40_000),
});
await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps[0].truncated).toBe(true);
expect(String(runSteps[0].output)).toMatch(/truncated/);
});
it("stops once the run deadline has passed", async () => {
defineAutomation(
[step({ id: "a", type: "run_command" }), step({ id: "b", type: "http" })],
{ maxRunSeconds: 1 },
);
// The first step consumes the whole budget, so the second must not start.
executeStep.mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + 5_000);
return { success: true, output: "slow" };
});
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const outcome = await AutomationEngine.getInstance().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("timeout");
expect(executeStep).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,137 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listAutomationWatchedHosts = vi.fn();
vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
}));
const {
automationSessionId,
reconcileHeadlessViewers,
releaseHeadlessViewers,
setViewerRegistry,
} =
await import("../../../../../plugins/automations/backend/headless-viewer.js");
const registry = {
registerViewer: vi.fn(),
unregisterViewer: vi.fn(),
updateHeartbeat: vi.fn(() => true),
};
beforeEach(() => {
// Drop any viewers the previous test left behind before the mocks are
// cleared, so those unregister calls are not counted against this test.
releaseHeadlessViewers();
vi.clearAllMocks();
setViewerRegistry(registry);
listAutomationWatchedHosts.mockResolvedValue(new Map());
});
describe("reconcileHeadlessViewers", () => {
it("registers a synthetic viewer for each watched host", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[9, "user-2"],
]),
);
const result = await reconcileHeadlessViewers();
expect(result.added).toBe(2);
expect(registry.registerViewer).toHaveBeenCalledWith(
7,
"automation:7",
"user-1",
);
expect(registry.registerViewer).toHaveBeenCalledWith(
9,
"automation:9",
"user-2",
);
});
it("heartbeats instead of re-registering a host it already holds", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
registry.registerViewer.mockClear();
const second = await reconcileHeadlessViewers();
// The 120s reaper drops viewers with a stale heartbeat, so every tick has
// to refresh the ones it is keeping.
expect(registry.updateHeartbeat).toHaveBeenCalledWith("automation:7");
expect(registry.registerViewer).not.toHaveBeenCalled();
expect(second.added).toBe(0);
expect(second.active).toBe(1);
});
it("releases a viewer once no automation watches the host", async () => {
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
await reconcileHeadlessViewers();
listAutomationWatchedHosts.mockResolvedValue(new Map());
const result = await reconcileHeadlessViewers();
expect(result.removed).toBe(1);
expect(result.active).toBe(0);
expect(registry.unregisterViewer).toHaveBeenCalledWith(7, "automation:7");
});
it("does nothing when no registry has been wired up", async () => {
setViewerRegistry(null);
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
const result = await reconcileHeadlessViewers();
expect(result).toEqual({ added: 0, removed: 0, active: 0 });
expect(registry.registerViewer).not.toHaveBeenCalled();
});
it("keeps going when the watch list cannot be loaded", async () => {
listAutomationWatchedHosts.mockRejectedValue(new Error("db down"));
await expect(reconcileHeadlessViewers()).resolves.toMatchObject({
added: 0,
});
});
it("survives a registry that throws on register", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
registry.registerViewer.mockImplementationOnce(() => {
throw new Error("nope");
});
const result = await reconcileHeadlessViewers();
// One host failing must not stop the other from being registered.
expect(result.added).toBe(1);
});
});
describe("releaseHeadlessViewers", () => {
it("drops every viewer it is holding", async () => {
listAutomationWatchedHosts.mockResolvedValue(
new Map([
[7, "user-1"],
[8, "user-1"],
]),
);
await reconcileHeadlessViewers();
releaseHeadlessViewers();
expect(registry.unregisterViewer).toHaveBeenCalledTimes(2);
});
});
describe("automationSessionId", () => {
it("namespaces the session so it cannot collide with a real viewer", () => {
expect(automationSessionId(42)).toBe("automation:42");
});
});
@@ -1,44 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const automationFetch = vi.fn();
vi.mock("../../../../../plugins/automations/backend/http.js", () => ({
automationFetch: (...args: unknown[]) => automationFetch(...args),
}));
const { sendAutomationNotification } =
await import("../../../../../plugins/automations/backend/notify.js");
beforeEach(() => {
automationFetch.mockReset();
automationFetch.mockResolvedValue({ ok: true });
});
describe("sendAutomationNotification", () => {
it("keeps alert-compatible host and rule fields in webhook payloads", async () => {
await sendAutomationNotification(
{ id: 1, type: "webhook", config: '{"url":"https://example.com"}' },
{
title: "CPU warning",
body: "cpu.percent is at 97",
severity: "warning",
context: {
host: { id: 11, name: "Proxmox Node" },
trigger: { value: 97, threshold: 90 },
run: { automationId: 42 },
},
},
);
const options = automationFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(options.body as string)).toMatchObject({
hostName: "Proxmox Node",
hostId: 11,
ruleName: "CPU warning",
ruleId: 42,
value: 97,
threshold: 90,
message: "cpu.percent is at 97",
});
});
});
@@ -1,511 +0,0 @@
import crypto from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, Response, Router } from "express";
import type { AutomationDefinition } from "../../../../types/automations.js";
/**
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
* the HTTP layer accepts, rejects and hands back.
*/
const state = vi.hoisted(() => ({
currentUserId: "user-1",
rows: [] as Array<Record<string, unknown>>,
nextId: 1,
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
const repository = vi.hoisted(() => ({
list: vi.fn(),
findForUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
listAllEnabled: vi.fn(),
listRuns: vi.fn(),
findRunForUser: vi.fn(),
listRunSteps: vi.fn(),
upsertSchedule: vi.fn(),
deleteSchedule: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.hoisted(() => vi.fn());
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
vi.mock("../../../database/routes/automation-dispatch.js", () => ({
registerAutomationsRouter: vi.fn(),
unregisterAutomationsRouter: vi.fn(),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => undefined),
getAuditUsername: vi.fn(async () => "alice"),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { router } =
await import("../../../../../plugins/automations/backend/routes.js");
/** Runs the whole middleware chain for the route, not just its handler. */
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
} = {},
) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (...args: unknown[]) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
query: overrides.query ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
for (const entry of layer.route.stack) {
let advanced = false;
await entry.handle(req, res, () => {
advanced = true;
});
if (!advanced) break;
}
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
function definition(
overrides: Partial<AutomationDefinition> = {},
): AutomationDefinition {
return {
version: 1,
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
},
steps: [{ id: "a", type: "notify", channelIds: [1] }],
...overrides,
} as AutomationDefinition;
}
beforeEach(() => {
state.currentUserId = "user-1";
state.rows = [];
state.nextId = 1;
vi.clearAllMocks();
repository.list.mockImplementation(async (userId: string) =>
state.rows.filter((row) => row.user_id === userId),
);
repository.findForUser.mockImplementation(
async (id: number, userId: string) =>
state.rows.find((row) => row.id === id && row.user_id === userId) ?? null,
);
repository.create.mockImplementation(
async (input: Record<string, unknown>) => {
const row = {
id: state.nextId++,
user_id: input.userId,
name: input.name,
definition: input.definition,
enabled: input.enabled === false ? 0 : 1,
channels: input.channels ?? [],
};
state.rows.push(row);
return row;
},
);
repository.delete.mockImplementation(async (id: number, userId: string) => {
const index = state.rows.findIndex(
(row) => row.id === id && row.user_id === userId,
);
if (index === -1) return false;
state.rows.splice(index, 1);
return true;
});
repository.listAllEnabled.mockImplementation(async () =>
state.rows.map((row) => ({
id: row.id,
userId: row.user_id,
definition: row.definition,
enabled: true,
})),
);
repository.upsertSchedule.mockResolvedValue(undefined);
repository.deleteSchedule.mockResolvedValue(undefined);
run.mockResolvedValue({ runId: 1, status: "success" });
});
describe("POST /", () => {
it("creates an automation from a valid definition", async () => {
const res = await invoke("post", "/", {
body: { name: "Disk watch", definition: definition() },
});
expect(res.statusCode).toBe(201);
expect(res.jsonBody?.name).toBe("Disk watch");
});
it("requires a name", async () => {
const res = await invoke("post", "/", {
body: { definition: definition() },
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/name/i);
});
it("rejects an unknown trigger kind", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: { version: 1, trigger: { kind: "nope" }, steps: [] },
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/trigger/i);
});
it("rejects an unknown operator", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: "~=",
value: 1,
cooldownMinutes: 5,
},
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/operator/i);
});
it("rejects an unknown step type", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [{ id: "a", type: "launch_missiles" }],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/step type/i);
});
it("rejects duplicate step ids, including inside a branch", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
steps: [
{ id: "dup", type: "wait", seconds: 1 },
{
id: "branch",
type: "if",
condition: { left: "1", operator: "==", right: "1" },
then: [{ id: "dup", type: "wait", seconds: 1 }],
},
],
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/duplicate/i);
});
it("rejects an invalid cron expression", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: { kind: "schedule", cron: "not a cron" },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/cron/i);
});
it("rejects an interval under a minute", async () => {
const res = await invoke("post", "/", {
body: {
name: "Bad",
definition: definition({
trigger: { kind: "schedule", intervalSeconds: 5 },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(400);
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
});
it("registers a schedule for a schedule trigger", async () => {
await invoke("post", "/", {
body: {
name: "Nightly",
definition: definition({
trigger: { kind: "schedule", cron: "0 2 * * *" },
} as Partial<AutomationDefinition>),
},
});
expect(repository.upsertSchedule).toHaveBeenCalled();
});
it("returns a webhook token once and stores only its hash", async () => {
const res = await invoke("post", "/", {
body: {
name: "Hooked",
definition: definition({
trigger: { kind: "webhook", tokenHash: "" },
} as Partial<AutomationDefinition>),
},
});
expect(res.statusCode).toBe(201);
const token = res.jsonBody?.webhookToken as string;
expect(token).toMatch(/^[a-f0-9]{64}$/);
const stored = JSON.parse(state.rows[0].definition as string);
expect(stored.trigger.tokenHash).not.toBe(token);
expect(stored.trigger.tokenHash).toBe(
crypto.createHash("sha256").update(token).digest("hex"),
);
// The hash is never echoed back to the client.
const body = res.jsonBody as { definition: AutomationDefinition };
expect((body.definition.trigger as { tokenHash: string }).tokenHash).toBe(
"",
);
});
it("stores the automation against the caller, not a supplied user id", async () => {
// Authorization here is ownership, the same as the other data routes:
// every read and write is scoped to req.userId, so a client cannot create
// an automation that belongs to somebody else.
await invoke("post", "/", {
body: {
name: "Mine",
definition: definition(),
userId: "user-2",
user_id: "user-2",
},
});
expect(state.rows).toHaveLength(1);
expect(state.rows[0].user_id).toBe("user-1");
});
});
describe("GET /", () => {
it("only returns the caller's automations", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("get", "/");
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toHaveLength(0);
});
});
describe("DELETE /:id", () => {
it("will not delete another user's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("delete", "/:id", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(state.rows).toHaveLength(1);
});
});
describe("POST /:id/run", () => {
function seedOwned() {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Mine",
definition: JSON.stringify(definition()),
channels: [],
});
}
it("runs an owned automation", async () => {
seedOwned();
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(200);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "manual" }),
);
});
it("passes the dry-run flag through", async () => {
seedOwned();
await invoke("post", "/:id/run", {
params: { id: "1" },
body: { dryRun: true },
});
expect(run).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true }));
});
it("refuses to run someone else's automation", async () => {
state.rows.push({
id: 1,
user_id: "user-2",
name: "Theirs",
definition: JSON.stringify(definition()),
channels: [],
});
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
});
describe("POST /webhook/:token", () => {
function seedWebhook(token: string) {
state.rows.push({
id: 1,
user_id: "user-1",
name: "Hooked",
definition: JSON.stringify(
definition({
trigger: {
kind: "webhook",
tokenHash: crypto.createHash("sha256").update(token).digest("hex"),
},
} as Partial<AutomationDefinition>),
),
channels: [],
});
}
it("runs the automation matching the token", async () => {
const token = "a".repeat(64);
seedWebhook(token);
const res = await invoke("post", "/webhook/:token", {
params: { token },
body: { hello: "world" },
});
expect(res.statusCode).toBe(202);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
);
});
it("rejects a token that does not match", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "b".repeat(64) },
});
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
it("rejects a token too short to be real", async () => {
seedWebhook("a".repeat(64));
const res = await invoke("post", "/webhook/:token", {
params: { token: "short" },
});
expect(res.statusCode).toBe(404);
expect(run).not.toHaveBeenCalled();
});
});
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import {
hasUnresolvedTokens,
redactSecrets,
renderRecord,
renderTemplate,
} from "../../../../../plugins/automations/backend/template.js";
const context = {
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
trigger: { value: 93.4, mount: "/data" },
steps: { check: { stdout: "ok\n", stderr: "", code: 0 } },
vars: { target: "/var/log" },
};
describe("renderTemplate", () => {
it("substitutes host, trigger, step and var tokens", () => {
expect(renderTemplate("{{host.name}} at {{trigger.value}}%", context)).toBe(
"Zeus at 93.4%",
);
expect(renderTemplate("{{steps.check.stdout}}", context)).toBe("ok\n");
expect(renderTemplate("{{vars.target}}", context)).toBe("/var/log");
});
it("tolerates whitespace inside the braces", () => {
expect(renderTemplate("{{ host.name }}", context)).toBe("Zeus");
});
it("leaves unknown tokens in place so typos are visible", () => {
expect(renderTemplate("{{host.nope}}", context)).toBe("{{host.nope}}");
expect(hasUnresolvedTokens(renderTemplate("{{host.nope}}", context))).toBe(
true,
);
});
it("returns the input untouched when there is nothing to render", () => {
expect(renderTemplate("plain text", context)).toBe("plain text");
expect(renderTemplate("", context)).toBe("");
});
it("does not escape or alter shell metacharacters", () => {
// Quoting is the caller's job; this keeps that boundary explicit.
const rendered = renderTemplate("{{vars.payload}}", {
vars: { payload: "; rm -rf /" },
});
expect(rendered).toBe("; rm -rf /");
});
it("does not recursively expand a value that looks like a token", () => {
const rendered = renderTemplate("{{vars.a}}", {
vars: { a: "{{vars.b}}", b: "gotcha" },
});
expect(rendered).toBe("{{vars.b}}");
});
it("serializes objects rather than printing [object Object]", () => {
expect(renderTemplate("{{trigger}}", { trigger: { a: 1 } })).toBe(
'{"a":1}',
);
});
it("renders missing branches as the literal token", () => {
expect(renderTemplate("{{steps.other.stdout}}", context)).toBe(
"{{steps.other.stdout}}",
);
});
});
describe("renderRecord", () => {
it("renders values and leaves keys alone", () => {
expect(renderRecord({ "X-Host": "{{host.name}}" }, context)).toEqual({
"X-Host": "Zeus",
});
});
it("passes undefined through", () => {
expect(renderRecord(undefined, context)).toBeUndefined();
});
});
describe("redactSecrets", () => {
it("masks credential-shaped keys", () => {
expect(
redactSecrets({
Authorization: "Bearer abc",
"X-Api-Key": "k",
token: "t",
password: "p",
Accept: "application/json",
}),
).toEqual({
Authorization: "***",
"X-Api-Key": "***",
token: "***",
password: "***",
Accept: "application/json",
});
});
});
@@ -1,445 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationDefinition,
Trigger,
} from "../../../../types/automations.js";
/**
* Trigger matching, dwell windows and cooldowns. The repository and the engine
* are mocked so these tests only exercise the decision of whether to fire.
*/
interface FakeRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
const rows: FakeRow[] = [];
const triggerState = new Map<string, Record<string, unknown>>();
const repository = {
listEnabledForUser: vi.fn(async (userId: string) =>
rows.filter((row) => row.userId === userId && row.enabled),
),
listAllEnabled: vi.fn(async () => rows.filter((row) => row.enabled)),
getTriggerState: vi.fn(async (automationId: number, stateKey: string) => {
return triggerState.get(`${automationId}:${stateKey}`) ?? null;
}),
upsertTriggerState: vi.fn(async (input: Record<string, unknown>) => {
const key = `${input.automationId}:${input.stateKey}`;
triggerState.set(key, { ...(triggerState.get(key) ?? {}), ...input });
}),
clearBreach: vi.fn(async (automationId: number, stateKey: string) => {
const key = `${automationId}:${stateKey}`;
const existing = triggerState.get(key);
if (existing) existing.breachStartedAt = null;
}),
};
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentAutomationRepository: () => repository,
}));
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
AutomationEngine: { getInstance: () => ({ run }) },
}));
const triggers =
await import("../../../../../plugins/automations/backend/triggers.js");
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
const row: FakeRow = {
id: overrides.id ?? rows.length + 1,
userId: overrides.userId ?? "user-1",
name: "Test",
enabled: overrides.enabled ?? true,
definition: JSON.stringify(definition),
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
};
rows.push(row);
return row;
}
const diskMetrics = {
disk: {
percent: 30,
filesystems: [
{ mount: "/", percent: 30 },
{ mount: "/data", percent: 93 },
],
},
};
beforeEach(() => {
rows.length = 0;
triggerState.clear();
vi.clearAllMocks();
});
describe("onMetrics", () => {
it("fires immediately when no dwell window is set", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0][0]).toMatchObject({
triggerType: "metric_threshold",
triggerHostId: 7,
});
});
it("watches the named mount rather than the aggregate", async () => {
// Root is at 30%, so a rule on / must not fire at a 90% threshold.
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("opens a dwell window instead of firing on the first sample", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
forSeconds: 600,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
expect(triggerState.get(`${row.id}:7:/data`)).toMatchObject({
breachStartedAt: expect.any(String),
});
});
it("fires once the dwell window has elapsed", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
forSeconds: 600,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("clears the window as soon as the value recovers", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 95,
forSeconds: 600,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
expect(repository.clearBreach).toHaveBeenCalled();
});
it("stays quiet while the cooldown is open", async () => {
const row = addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 7 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
triggerState.set(`${row.id}:7:/data`, {
lastFiredAt: new Date(Date.now() - 60_000).toISOString(),
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("ignores hosts outside the selector", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 99 },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
it("never evaluates another user's automations", async () => {
addAutomation(
{
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "disk.percent", mount: "/data" },
operator: ">",
value: 90,
cooldownMinutes: 15,
},
{ userId: "user-2" },
);
await triggers.onMetrics({
hostId: 7,
ownerUserId: "user-1",
metrics: diskMetrics,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onStatus", () => {
it("treats the first observation as a baseline", async () => {
addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
// Otherwise every host would announce itself after a restart.
expect(run).not.toHaveBeenCalled();
});
it("fires on a transition into the watched state", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "online" });
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("does not fire on a transition in the other direction", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
await triggers.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
expect(run).not.toHaveBeenCalled();
});
it("stays quiet while the state is unchanged", async () => {
const row = addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 7 },
to: "offline",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
await triggers.onStatus({
hostId: 7,
ownerUserId: "user-1",
online: false,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onHealthCheck", () => {
it("fires when a check transitions to failing", async () => {
const row = addAutomation({
kind: "health_check",
hostSelector: { kind: "host", hostId: 7 },
to: "failing",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
await triggers.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "web",
ok: false,
});
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores a different check id", async () => {
const row = addAutomation({
kind: "health_check",
hostSelector: { kind: "host", hostId: 7 },
checkId: "db",
to: "failing",
cooldownMinutes: 0,
});
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
await triggers.onHealthCheck({
hostId: 7,
userId: "user-1",
checkId: "web",
ok: false,
});
expect(run).not.toHaveBeenCalled();
});
});
describe("onDockerEvent", () => {
it("fires for a matching container and event", async () => {
addAutomation({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 7 },
container: "api",
event: "exited",
cooldownMinutes: 0,
});
await triggers.onDockerEvent({
hostId: 7,
ownerUserId: "user-1",
container: "api",
event: "exited",
});
expect(run).toHaveBeenCalledTimes(1);
});
it("ignores a different container", async () => {
addAutomation({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 7 },
container: "api",
event: "exited",
cooldownMinutes: 0,
});
await triggers.onDockerEvent({
hostId: 7,
ownerUserId: "user-1",
container: "worker",
event: "exited",
});
expect(run).not.toHaveBeenCalled();
});
});
describe("listAutomationWatchedHosts", () => {
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
addAutomation({
kind: "metric_threshold",
hostSelector: { kind: "hosts", hostIds: [3, 4] },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
});
const watched = await triggers.listAutomationWatchedHosts();
expect([...watched.keys()].sort()).toEqual([3, 4]);
});
it("ignores triggers that do not need heavy collection", async () => {
addAutomation({
kind: "host_status",
hostSelector: { kind: "host", hostId: 3 },
to: "offline",
cooldownMinutes: 0,
});
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
});
});
@@ -90,7 +90,7 @@ describe("declared dependencies", () => {
}
});
// plugins/ai/backend/tools/executor.ts imports automations' routes
// plugins/ai/src/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
@@ -111,7 +111,7 @@ describe("plugins that own a port", () => {
(id) => {
if (!ids.includes(id)) return;
const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs");
const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts");
const source = fs.readFileSync(entry, "utf8");
const moduleScope = source.slice(
0,
@@ -129,8 +129,14 @@ describe("plugins that own a port", () => {
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
// every plugin's deactivate. A plugin calling process.exit() itself
// skipped the rest of that sequence.
for (const file of ["index.mjs", "console.ts"]) {
const candidate = path.join(BUNDLED_DIR, "docker", "backend", file);
for (const file of ["index.ts", "console.ts"]) {
const candidate = path.join(
BUNDLED_DIR,
"docker",
"src",
"backend",
file,
);
if (!fs.existsSync(candidate)) continue;
// Comments are stripped first: the rule is worth explaining in prose
+1 -1
View File
@@ -137,7 +137,7 @@ describe("hosts publishers route through the bus", () => {
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
const { notifyAutomationStatus, notifyAutomationMetrics } =
await import("../../../../plugins/host-metrics/backend/automation-bridge.js");
await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js");
const status = vi.fn();
const metrics = vi.fn();
@@ -1,437 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
const state = vi.hoisted(() => ({
currentUserId: "user-1",
fleets: new Map<number, { id: number; userId: string; name: string }>(),
members: new Map<number, { id: number; name: string }[]>(),
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
hosts: new Map<number, Record<string, unknown>>(),
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, hostId: number, level: string) => {
const key = `${userId}:${hostId}:${level}`;
const found = state.hostAccess.get(key);
if (found) return found;
// default: full access unless a test explicitly denies it
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
},
}),
},
}));
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
}));
vi.mock("../../../hosts/ssh-client-factory.js", () => ({
getFleetPoolKey: () => "pool-key",
createFleetSshFactory: () => async () => ({}),
}));
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
_factory: unknown,
fn: (client: unknown) => unknown,
) => fn({}),
}));
vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
}));
vi.mock("../../../hosts/metrics-shared/platform.js", () => ({
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
}));
vi.mock("../../../hosts/metrics-shared/exec-elevated.js", async () => {
class ElevationError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
return {
execElevated: vi.fn(),
ElevationError,
};
});
vi.mock("../../../hosts/metrics-shared/package-commands.js", () => ({
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
}));
vi.mock("../../../hosts/metrics-shared/validation.js", () => ({
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
}));
vi.mock("../../../database/routes/snippets-execution.js", () => ({
resolveSnippetCommand: (command: string) => command,
}));
vi.mock("../../../database/routes/rbac.js", () => ({
isSharePermissionLevel: (v: unknown) =>
["connect", "view", "edit", "manage"].includes(v as string),
expiryFromDuration: () => null,
parseShareTargets: () => null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentFleetRepository: () => ({
findById: async (userId: string, fleetId: number) => {
const fleet = state.fleets.get(fleetId);
return fleet && fleet.userId === userId ? fleet : null;
},
listEffectiveMembers: async (_userId: string, fleetId: number) =>
state.members.get(fleetId) ?? [],
listStaticMemberIds: async () => [],
listByUser: async (userId: string) =>
[...state.fleets.values()].filter((f) => f.userId === userId),
}),
createCurrentFleetInventoryRepository: () => ({
listForHosts: async () => [],
upsert: async () => ({}),
}),
createCurrentRbacAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentUserRepository: () => ({}),
}));
vi.mock("../../../database/routes/fleet-dispatch.js", () => ({
registerFleetsRouter: vi.fn(),
unregisterFleetsRouter: vi.fn(),
}));
const { router, parseInventoryProbe, buildRemoveCommand } =
await import("../../../../../plugins/fleets/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
state.members = new Map([
[
1,
[
{ id: 10, name: "host-a" },
{ id: 11, name: "host-b" },
],
],
]);
state.hostAccess = new Map();
state.hosts = new Map([
[
10,
{
id: 10,
userId: "user-1",
name: "host-a",
ip: "10.0.0.1",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
[
11,
{
id: 11,
userId: "user-1",
name: "host-b",
ip: "10.0.0.2",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
]);
});
describe("GET /:id/members", () => {
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "999" },
});
expect(res.statusCode).toBe(404);
});
it("400s on a non-numeric fleet id", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "not-a-number" },
});
expect(res.statusCode).toBe(400);
});
});
describe("POST /:id/execute", () => {
it("400s when command is missing", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: {},
});
expect(res.statusCode).toBe(400);
});
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
hostId: 10,
hostName: "host-a",
success: true,
});
});
it("isolates one host's access denial - the other host still succeeds", async () => {
state.hostAccess.set("user-1:10:edit", {
hasAccess: false,
isOwner: false,
});
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
const denied = results.find((r) => r.hostId === 10);
const allowed = results.find((r) => r.hostId === 11);
expect(denied).toMatchObject({ success: false });
expect(String(denied?.error)).toMatch(/edit/);
expect(allowed).toMatchObject({ success: true });
});
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "999" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(404);
});
});
describe("POST /:id/packages", () => {
it("400s on an invalid action", async () => {
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "reformat-disk" },
});
expect(res.statusCode).toBe(400);
});
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
const { execElevated, ElevationError } =
await import("../../../hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
new ElevationError("SUDO_REQUIRED", "sudo password required"),
);
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "install", package: "curl" },
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(results[0].error).toMatch(/sudo password required/);
});
it("requires manage-level access, not just edit", async () => {
state.hostAccess.set("user-1:10:manage", {
hasAccess: false,
isOwner: false,
});
state.hostAccess.set("user-1:11:manage", {
hasAccess: false,
isOwner: false,
});
const { execElevated } =
await import("../../../hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
code: 0,
stdout: "done",
stderr: "",
});
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "upgrade-all" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(String(results[0].error)).toMatch(/manage/);
});
});
describe("POST /:id/members", () => {
it("404s when the caller cannot access the target host", async () => {
state.hostAccess.set("user-1:99:connect", {
hasAccess: false,
isOwner: false,
});
const res = await invoke("post", "/:id/members", {
params: { id: "1" },
body: { hostId: 99 },
});
expect(res.statusCode).toBe(404);
});
});
describe("parseInventoryProbe", () => {
it("extracts kernel, arch, hostname, and uptime from key=value lines", () => {
const out = [
"kernel=6.1.0-generic",
"arch=x86_64",
"hostname=web-1",
"uptime_seconds=123456",
].join("\n");
expect(parseInventoryProbe(out)).toEqual({
kernel: "6.1.0-generic",
architecture: "x86_64",
hostname: "web-1",
uptimeSeconds: 123456,
});
});
it("nulls out fields missing from the probe output", () => {
expect(parseInventoryProbe("kernel=6.1.0")).toEqual({
kernel: "6.1.0",
architecture: null,
hostname: null,
uptimeSeconds: null,
});
});
it("nulls uptimeSeconds when the value is not a bare integer", () => {
const out = "uptime_seconds=";
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
});
it("ignores lines with no '=' separator", () => {
const out = ["garbage line", "kernel=6.1.0"].join("\n");
expect(parseInventoryProbe(out).kernel).toBe("6.1.0");
});
});
describe("buildRemoveCommand", () => {
it("builds the correct remove command per package manager", () => {
expect(buildRemoveCommand("apt", "curl")).toContain(
"apt-get -y remove curl",
);
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
expect(buildRemoveCommand("pacman", "curl")).toBe(
"pacman -R --noconfirm curl",
);
});
it("returns null when no package manager was detected", () => {
expect(buildRemoveCommand(null, "curl")).toBeNull();
});
});
@@ -1,136 +0,0 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
parseStatusHostIds,
tcpPingThroughJumpHost,
} from "../../../../../plugins/host-metrics/backend/helpers.js";
import { createConnectionLog } from "../../../hosts/connection-log.js";
describe("supportsMetrics", () => {
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
).toBe(true);
});
it("defaults missing connectionType to ssh", () => {
expect(supportsMetrics({ authType: "key" })).toBe(true);
});
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
false,
);
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
false,
);
});
});
describe("isTcpPingEnabled", () => {
it("is enabled when status checks are on and tcp ping is not disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
).toBe(true);
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
});
it("is disabled when status checks are off", () => {
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
});
it("is disabled when tcp ping is explicitly disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
).toBe(false);
});
});
describe("parseStatusHostIds", () => {
it("distinguishes an unrestricted request from an empty host set", () => {
expect(parseStatusHostIds(undefined)).toBeNull();
expect(parseStatusHostIds("")).toEqual(new Set());
});
it("keeps only valid positive host IDs", () => {
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
});
});
describe("createConnectionLog", () => {
it("builds a log entry without id/timestamp", () => {
const entry = createConnectionLog("info", "connection", "Connecting", {
hostId: 1,
});
expect(entry).toEqual({
type: "info",
stage: "connection",
message: "Connecting",
details: { hostId: 1 },
});
expect("id" in entry).toBe(false);
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
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();
});
});
@@ -1,97 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL(
"../../../../../plugins/host-metrics/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
node.expression.expression.getText(ast) === "app.post" &&
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
app: {
post: (_path: string, callback: typeof handler) => {
handler = callback;
},
},
require: () => ({
SystemCrypto: {
getInstance: () => ({
getInternalAuthToken: async () => "internal-test-token",
}),
},
}),
notifyAutomationInternalEvent: notify,
},
);
const body = {
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
};
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", async () => {
const res = response();
await handler(
{
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-internal-auth": "internal-test-token" },
body,
},
res,
);
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
it.each([
["192.0.2.9", "internal-test-token"],
["127.0.0.1", "incorrect"],
])(
"rejects untrusted login notifications (%s)",
async (remoteAddress, token) => {
const res = response();
await handler(
{
socket: { remoteAddress },
headers: { "x-internal-auth": token },
body,
},
res,
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
},
);
@@ -1,30 +0,0 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for: the /internal/login-alert route was registered
// after the global JWT auth middleware, so every service-to-service login
// alert got rejected with 401 before the route's own IP+token check ever
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
// polling managers, etc.) just to hit this one route is out of scope, so
// this asserts the registration order directly against the source instead.
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
const authMiddlewareIndex = source.indexOf(
"app.use(authManager.createAuthMiddleware())",
);
expect(routeIndex).toBeGreaterThan(-1);
expect(authMiddlewareIndex).toBeGreaterThan(-1);
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
});
});
@@ -1,440 +0,0 @@
import { describe, it, expect } from "vitest";
import {
buildSudoCommand,
shellSingleQuote,
} from "../../../../hosts/metrics-shared/exec-elevated.js";
import { parsePlatformProbe } from "../../../../hosts/metrics-shared/platform.js";
import {
isValidSystemdUnit,
isValidPid,
isValidPort,
isValidPackageName,
isValidUsername,
isValidDomain,
isValidDnsProvider,
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "../../../../hosts/metrics-shared/validation.js";
import {
parseServiceList,
buildServiceActionCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/services.js";
import {
parseProcessList,
buildKillCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/processes.js";
import {
parseDfMounts,
parseTopMemory,
} from "../../../../../../plugins/host-metrics/backend/managers/simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "../../../../hosts/metrics-shared/package-commands.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "../../../../../../plugins/host-metrics/backend/managers/ssl.js";
import {
buildIptablesRuleCommand,
buildNftRuleCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/firewall.js";
import {
parsePasswd,
parseSudoers,
} from "../../../../../../plugins/host-metrics/backend/managers/users.js";
import {
buildHealthCheckCommand,
parseHealthResult,
} from "../../../../../../plugins/host-metrics/backend/managers/health.js";
import {
buildTailCommand,
clampLines,
} from "../../../../../../plugins/host-metrics/backend/managers/logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
expect(shellSingleQuote("abc")).toBe("'abc'");
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
});
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
);
});
it("does not merge stderr into stdout (no 2>&1)", () => {
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
});
it("escapes a password containing a quote", () => {
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
});
});
describe("platform probe parsing", () => {
it("parses capabilities and prefers dnf over yum", () => {
const out = [
"systemd=1",
"apt=0",
"dnf=1",
"yum=1",
"pacman=0",
"certbot=1",
"acmesh=0",
"docker=1",
"os=Fedora Linux 40",
].join("\n");
const p = parsePlatformProbe(out);
expect(p.hasSystemd).toBe(true);
expect(p.pkg).toBe("dnf");
expect(p.hasCertbot).toBe(true);
expect(p.hasAcmeSh).toBe(false);
expect(p.hasDocker).toBe(true);
expect(p.osPrettyName).toBe("Fedora Linux 40");
});
it("picks apt when present", () => {
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
});
});
describe("validation (injection defense)", () => {
it("systemd units", () => {
expect(isValidSystemdUnit("nginx.service")).toBe(true);
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
expect(isValidSystemdUnit("nginx")).toBe(false);
});
it("pids", () => {
expect(isValidPid(123)).toBe(true);
expect(isValidPid("123")).toBe(true);
expect(isValidPid(0)).toBe(false);
expect(isValidPid("1; reboot")).toBe(false);
expect(isValidPid(-5)).toBe(false);
});
it("ports", () => {
expect(isValidPort(443)).toBe(true);
expect(isValidPort(0)).toBe(false);
expect(isValidPort(70000)).toBe(false);
});
it("package names", () => {
expect(isValidPackageName("nginx")).toBe(true);
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
expect(isValidPackageName("nginx && curl evil")).toBe(false);
expect(isValidPackageName("-rf")).toBe(false);
});
it("usernames and domains", () => {
expect(isValidUsername("deploy")).toBe(true);
expect(isValidUsername("root; rm")).toBe(false);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("*.example.com")).toBe(true);
expect(isValidDomain("ex ample.com")).toBe(false);
expect(isValidDomain("a;b.com")).toBe(false);
});
it("dns providers, signals, service actions", () => {
expect(isValidDnsProvider("cloudflare")).toBe(true);
expect(isValidDnsProvider("cf; rm")).toBe(false);
expect(isValidSignal("KILL")).toBe(true);
expect(isValidSignal("BOOM")).toBe(false);
expect(isValidServiceAction("restart")).toBe(true);
expect(isValidServiceAction("destroy")).toBe(false);
});
it("path allowlist rejects traversal and out-of-allowlist", () => {
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
false,
);
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
});
});
describe("services", () => {
it("parses list-units --plain", () => {
const out =
"nginx.service loaded active running A high performance web server\n" +
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
"cron.service loaded inactive dead Regular background program";
const rows = parseServiceList(out);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
expect(rows[2].active).toBe("inactive");
});
it("builds action command", () => {
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
"systemctl restart nginx.service",
);
});
});
describe("processes", () => {
it("parses ps output", () => {
const out =
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
const rows = parseProcessList(out);
expect(rows[0]).toMatchObject({
pid: 1234,
user: "root",
cpu: 12.5,
command: "/usr/bin/node",
});
expect(rows[1].pid).toBe(5678);
});
it("builds kill command", () => {
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
});
});
describe("simple reads", () => {
it("parses df -Pk and skips tmpfs", () => {
const out =
"/dev/sda1 100000 40000 60000 40% /\n" +
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"/dev/sdb1 200000 100000 100000 50% /data";
const mounts = parseDfMounts(out);
expect(mounts).toHaveLength(2);
expect(mounts[0].mount).toBe("/");
expect(mounts[1].usePct).toBe(50);
});
it("parses top by memory", () => {
const rows = parseTopMemory("1234 root 5.5 50000 node");
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
});
});
describe("cron", () => {
it("parses enabled and toggled entries", () => {
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
const entries = parseCrontab(out);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
expect(entries[1].enabled).toBe(false);
});
it("validates schedules", () => {
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
expect(isValidCronSchedule("@daily")).toBe(true);
expect(isValidCronSchedule("not a schedule")).toBe(false);
});
it("serializes (commenting disabled entries) and round-trips", () => {
const body = serializeCrontab([
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
]);
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
const reparsed = parseCrontab(body);
expect(reparsed[0].enabled).toBe(true);
expect(reparsed[1].enabled).toBe(false);
});
it("builds apply command piping into crontab -", () => {
expect(buildApplyCrontabCommand("x\n")).toBe(
`printf '%s' 'x\n' | crontab -`,
);
});
});
describe("packages", () => {
it("builds per-distro commands", () => {
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
"apt-get -y install nginx",
);
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
"pacman -Syu --noconfirm",
);
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
});
it("lists per distro", () => {
expect(buildListUpgradableCommand("apt")).toContain(
"apt list --upgradable",
);
expect(buildListUpgradableCommand(null)).toBeNull();
});
it("parses apt upgradable", () => {
const out =
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
const pkgs = parseUpgradable("apt", out);
expect(pkgs[0]).toMatchObject({
name: "nginx",
newVersion: "1.18.0-2",
currentVersion: "1.18.0-1",
});
});
it("parses pacman upgradable", () => {
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
});
});
describe("ssl (dual client)", () => {
it("builds certbot issue for http + dns", () => {
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "http-standalone",
}),
).toContain(
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
);
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "dns",
dnsProvider: "cloudflare",
}),
).toContain("--dns-cloudflare");
});
it("builds acme.sh issue", () => {
const cmd = buildIssueCommand({
client: "acme.sh",
domains: ["a.com", "b.com"],
challenge: "dns",
dnsProvider: "cf",
});
expect(cmd).toContain("--issue --dns dns_cf");
expect(cmd).toContain("-d 'a.com'");
expect(cmd).toContain("-d 'b.com'");
});
it("builds renew per client", () => {
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
});
it("builds revoke per client", () => {
expect(buildRevokeCommand("certbot", "example.com")).toBe(
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
);
const acme = buildRevokeCommand("acme.sh", "example.com");
expect(acme).toContain("--revoke -d 'example.com'");
expect(acme).toContain("--remove -d 'example.com'");
});
it("validates certificate names (rejects shell metachars)", () => {
expect(isValidCertName("example.com")).toBe(true);
expect(isValidCertName("example.com-0001")).toBe(true);
expect(isValidCertName("*.example.com")).toBe(true);
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
expect(isValidCertName("")).toBe(false);
expect(isValidCertName(undefined)).toBe(false);
});
it("parses certbot certificates", () => {
const out = [
"Found the following certs:",
" Certificate Name: example.com",
" Domains: example.com www.example.com",
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
].join("\n");
const certs = parseCertbotCertificates(out);
expect(certs[0]).toMatchObject({
name: "example.com",
client: "certbot",
});
expect(certs[0].domains).toContain("www.example.com");
});
});
describe("firewall", () => {
it("builds iptables add/delete on INPUT only", () => {
expect(
buildIptablesRuleCommand("add", {
protocol: "tcp",
port: 443,
target: "ACCEPT",
}),
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
expect(
buildIptablesRuleCommand("delete", {
protocol: "udp",
port: 53,
target: "DROP",
}),
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
});
it("builds nft rules", () => {
expect(
buildNftRuleCommand("add", {
protocol: "tcp",
port: 22,
target: "ACCEPT",
}),
).toContain("add rule inet filter input tcp dport 22 accept");
});
});
describe("users", () => {
it("parses passwd for human users only", () => {
const out =
"root:x:0:0:root:/root:/bin/bash\n" +
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
const users = parsePasswd(out);
expect(users).toHaveLength(1);
expect(users[0].name).toBe("deploy");
});
it("parses sudoers membership", () => {
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
});
});
describe("health checks", () => {
it("builds tcp and http commands", () => {
const tcp = buildHealthCheckCommand({
id: "1",
name: "ssh",
type: "tcp",
target: "localhost",
port: 22,
});
expect(tcp).toContain("/dev/tcp/");
const http = buildHealthCheckCommand({
id: "2",
name: "web",
type: "http",
target: "example.com",
path: "/health",
});
expect(http).toContain("curl -s -o /dev/null");
expect(http).toContain("http://example.com/health");
});
it("parses tcp and http results", () => {
const tcp = parseHealthResult(
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
"ok 12",
);
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
const http = parseHealthResult(
{ id: "2", name: "web", type: "http", target: "h" },
"200 0.045",
);
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
const bad = parseHealthResult(
{ id: "3", name: "web", type: "http", target: "h" },
"500 0.01",
);
expect(bad.ok).toBe(false);
});
});
describe("logs", () => {
it("clamps line counts", () => {
expect(clampLines(50)).toBe(50);
expect(clampLines(99999)).toBe(2000);
expect(clampLines("abc")).toBe(200);
expect(clampLines(0)).toBe(1);
});
it("builds a quoted tail command without suppressing stderr", () => {
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
"tail -n 100 '/var/log/syslog'",
);
});
});
@@ -1,39 +0,0 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for Support#1216: routine status polling on a host with
// metrics disabled ran a full SSH authentication attempt on every interval,
// which fires a live 2FA push each cycle on RADIUS/Duo-backed devices with
// no active session. Spinning up the full metrics-service Express app (DB,
// SSH clients, timers, polling managers, etc.) just to drive pollHostStatus
// through two intervals is out of scope, so this asserts directly against
// the source that the status-poll path never calls into SSH auth, the same
// way login-alert-route-order.test.ts guards route registration order.
describe("metrics service status polling", () => {
it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => {
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
const pollHostStatusStart = source.indexOf("private async pollHostStatus(");
const pollHostMetricsStart = source.indexOf(
"private async pollHostMetrics(",
);
expect(pollHostStatusStart).toBeGreaterThan(-1);
expect(pollHostMetricsStart).toBeGreaterThan(pollHostStatusStart);
const pollHostStatusBody = source.slice(
pollHostStatusStart,
pollHostMetricsStart,
);
expect(pollHostStatusBody).not.toContain("withSshConnection");
expect(pollHostStatusBody).not.toContain("statusAfterAuthentication");
});
});
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import {
parseCpuLine,
parseDarwinCpuUsageLine,
} from "../../../../../../plugins/host-metrics/backend/widgets/cpu-collector.js";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDarwinCpuUsageLine", () => {
it("parses macOS `top -l 1` CPU usage line", () => {
const result = parseDarwinCpuUsageLine(
"CPU usage: 12.34% user, 5.66% sys, 82.00% idle",
);
expect(result?.percent).toBeCloseTo(18.0, 5);
});
it("returns undefined for unrelated lines", () => {
expect(parseDarwinCpuUsageLine("Processes: 400 total")).toBeUndefined();
});
});
@@ -1,319 +0,0 @@
import { describe, it, expect } from "vitest";
import {
parseDfLines,
findWorstMountIndex,
buildFilesystemList,
selectPrimaryFilesystem,
filterExcludedFilesystems,
mergeMonitoredFilesystems,
parseWindowsDiskJson,
parseDarwinMountTypes,
parseDarwinDfRows,
} from "../../../../../../plugins/host-metrics/backend/widgets/disk-collector.js";
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
// Representative output shape from `df -Pk` and `mount` on a modern macOS
// (Sonoma/Sequoia, Apple Silicon) system, where the APFS root volume group
// surfaces as several synthetic system volumes alongside the real "/" and
// "/System/Volumes/Data" mounts.
const DARWIN_DF_OUTPUT =
"Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/disk3s1s1 971350180 10904432 723545416 2% /\n" +
"devfs 205 205 0 100% /dev\n" +
"/dev/disk3s6 971350180 8437248 723545416 2% /System/Volumes/VM\n" +
"/dev/disk3s2 971350180 6067864 723545416 1% /System/Volumes/Preboot\n" +
"/dev/disk3s4 971350180 16612 723545416 1% /System/Volumes/Update\n" +
"/dev/disk1s2 500098 6104 479950 2% /System/Volumes/xarts\n" +
"/dev/disk1s1 500098 5388 479950 2% /System/Volumes/iSCPreboot\n" +
"/dev/disk1s3 500098 36 479950 1% /System/Volumes/Hardware\n" +
"/dev/disk3s5 971350180 235925064 723545416 25% /System/Volumes/Data\n" +
"map auto_home 0 0 0 100% /System/Volumes/Data/home\n";
const DARWIN_MOUNT_OUTPUT =
"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n" +
"devfs on /dev (devfs, local, nobrowse)\n" +
"/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk3s2 on /System/Volumes/Preboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s4 on /System/Volumes/Update (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s2 on /System/Volumes/xarts (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk1s1 on /System/Volumes/iSCPreboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s3 on /System/Volumes/Hardware (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled, nobrowse)\n" +
"map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\n";
describe("parseDarwinMountTypes", () => {
it("maps each mount point to its filesystem type", () => {
const map = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
expect(map.get("/")).toBe("apfs");
expect(map.get("/dev")).toBe("devfs");
expect(map.get("/System/Volumes/Data/home")).toBe("autofs");
});
});
describe("parseDarwinDfRows", () => {
it("parses real `df -Pk` rows and drops devfs/autofs and synthetic system volumes", () => {
const typeByMount = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
const rows = parseDarwinDfRows(DARWIN_DF_OUTPUT, typeByMount);
expect(rows.map((r) => r.mount)).toEqual(["/", "/System/Volumes/Data"]);
const root = rows[0];
expect(root.type).toBe("apfs");
expect(root.totalBytes).toBe(971350180 * 1024);
expect(root.usedBytes).toBe(10904432 * 1024);
expect(root.availableBytes).toBe(723545416 * 1024);
// Our percent is used/total (matches the Linux/Windows collectors), which
// differs slightly from df's own Capacity% (used/(used+available)).
expect(root.percent).toBe(1);
expect(root.usedHuman).toBeTruthy();
expect(root.totalHuman).toBeTruthy();
});
it("returns an empty list for blank output", () => {
expect(parseDarwinDfRows("", new Map())).toEqual([]);
});
});
describe("parseWindowsDiskJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsDiskJson(
'{"drive":"C:","total":1000000000,"free":400000000}',
);
expect(rows).toEqual([{ drive: "C:", total: 1000000000, free: 400000000 }]);
});
it("parses an array of drives", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":1000,"free":400},{"drive":"D:","total":2000,"free":1900}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({ drive: "D:", total: 2000, free: 1900 });
});
it("drops drives with a zero or missing total", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":0,"free":0},{"drive":"D:","total":100,"free":50}]',
);
expect(rows).toHaveLength(1);
expect(rows[0].drive).toBe("D:");
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsDiskJson("")).toEqual([]);
expect(parseWindowsDiskJson("not json")).toEqual([]);
});
});
@@ -1,179 +0,0 @@
import { describe, expect, it } from "vitest";
import {
parseNvidiaSmiGpus,
parseNvidiaSmiOutput,
parseNvidiaSmiProcesses,
} from "../../../../../../plugins/host-metrics/backend/widgets/gpu-collector.js";
const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b";
const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4";
// index, uuid, driver_version, utilization.gpu, memory.used, memory.total,
// temperature.gpu, power.draw, power.limit, fan.speed, name
const GPU_QUERY_OUTPUT = [
`0, ${RTX_UUID}, 550.54.14, 37, 8123, 24564, 61, 182.47, 450.00, 45, NVIDIA GeForce RTX 4090`,
`1, ${H100_UUID}, 550.54.14, 0, 1, 81559, 34, 62.10, 700.00, [N/A], NVIDIA H100 80GB HBM3`,
"",
].join("\n");
// gpu_uuid, pid, used_memory, process_name
const APPS_QUERY_OUTPUT = [
`${RTX_UUID}, 12345, 7890, /usr/bin/python3`,
`${H100_UUID}, 23456, [N/A], /opt/app/worker, gpu, v2`,
"",
].join("\n");
const DRIVER_FAILURE =
"NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver. Make sure that the latest NVIDIA driver is installed and running.\n";
const RTX = {
index: 0,
uuid: RTX_UUID,
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent: 37,
memoryUsedMiB: 8123,
memoryTotalMiB: 24564,
memoryPercent: 33.1,
temperatureCelsius: 61,
powerDrawWatts: 182.5,
powerLimitWatts: 450,
fanPercent: 45,
};
const H100 = {
index: 1,
uuid: H100_UUID,
name: "NVIDIA H100 80GB HBM3",
driverVersion: "550.54.14",
utilizationPercent: 0,
memoryUsedMiB: 1,
memoryTotalMiB: 81559,
memoryPercent: 0,
temperatureCelsius: 34,
powerDrawWatts: 62.1,
powerLimitWatts: 700,
fanPercent: null,
};
describe("gpu collector", () => {
describe("parseNvidiaSmiGpus", () => {
it("parses one device per row of --query-gpu output", () => {
expect(parseNvidiaSmiGpus(GPU_QUERY_OUTPUT)).toEqual([RTX, H100]);
});
it("maps unsupported and unavailable readings to null", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-aaaa, 470.182.03, [N/A], 512, [N/A], [Not Supported], [Not Supported], [N/A], [Unknown Error], Tesla K80\n",
);
expect(gpu).toEqual({
index: 0,
uuid: "GPU-aaaa",
name: "Tesla K80",
driverVersion: "470.182.03",
utilizationPercent: null,
memoryUsedMiB: 512,
memoryTotalMiB: null,
memoryPercent: null,
temperatureCelsius: null,
powerDrawWatts: null,
powerLimitWatts: null,
fanPercent: null,
});
});
it("keeps a device name that contains commas intact", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-bbbb, 535.0, 5, 10, 100, 40, 20, 70, 30, Quadro RTX, Mobile, Max-Q\n",
);
expect(gpu.name).toBe("Quadro RTX, Mobile, Max-Q");
expect(gpu.fanPercent).toBe(30);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiGpus(DRIVER_FAILURE)).toEqual([]);
expect(parseNvidiaSmiGpus("No devices were found\n")).toEqual([]);
});
it("skips a csv header row", () => {
const header =
"index, uuid, driver_version, utilization.gpu [%], memory.used [MiB], memory.total [MiB], temperature.gpu, power.draw [W], power.limit [W], fan.speed [%], name\n";
expect(parseNvidiaSmiGpus(header + GPU_QUERY_OUTPUT)).toEqual([
RTX,
H100,
]);
});
});
describe("parseNvidiaSmiProcesses", () => {
it("attaches each process to the GPU with the matching uuid", () => {
expect(parseNvidiaSmiProcesses(APPS_QUERY_OUTPUT, [RTX, H100])).toEqual([
{
gpuIndex: 0,
pid: 12345,
name: "/usr/bin/python3",
memoryUsedMiB: 7890,
},
{
gpuIndex: 1,
pid: 23456,
name: "/opt/app/worker, gpu, v2",
memoryUsedMiB: null,
},
]);
});
it("keeps processes whose uuid matches no listed GPU", () => {
expect(
parseNvidiaSmiProcesses("GPU-unknown, 77, 100, /bin/app\n", [RTX]),
).toEqual([
{ gpuIndex: null, pid: 77, name: "/bin/app", memoryUsedMiB: 100 },
]);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiProcesses(DRIVER_FAILURE, [RTX])).toEqual([]);
});
it("skips a csv header row", () => {
expect(
parseNvidiaSmiProcesses(
"gpu_uuid, pid, used_gpu_memory [MiB], process_name\n",
[RTX],
),
).toEqual([]);
});
});
describe("parseNvidiaSmiOutput", () => {
it("splits the combined command output into devices and processes", () => {
const result = parseNvidiaSmiOutput(
`${GPU_QUERY_OUTPUT}---\n${APPS_QUERY_OUTPUT}`,
);
expect(result.source).toBe("nvidia-smi");
expect(result.gpus).toEqual([RTX, H100]);
expect(result.processes.map((p) => [p.gpuIndex, p.pid])).toEqual([
[0, 12345],
[1, 23456],
]);
});
it("reports no source when nvidia-smi is not installed", () => {
expect(parseNvidiaSmiOutput("")).toEqual({
source: "none",
gpus: [],
processes: [],
});
});
it("reports no source when the driver cannot be reached", () => {
expect(
parseNvidiaSmiOutput(`${DRIVER_FAILURE}---\n${DRIVER_FAILURE}`),
).toEqual({ source: "none", gpus: [], processes: [] });
});
});
});
@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import {
counterRate,
parseNetworkCounters,
parseDarwinIfconfig,
parseDarwinNetstat,
parseWindowsAdapterJson,
} from "../../../../../../plugins/host-metrics/backend/widgets/network-collector.js";
const PROC_NET = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
eth0: 1024 1 0 0 0 0 0 0 2048 2 0 0 0 0 0 0
lo: 4096 4 0 0 0 0 0 0 4096 4 0 0 0 0 0 0`;
describe("network counters", () => {
it("parses Linux proc counters", () => {
expect(parseNetworkCounters(PROC_NET).get("eth0")).toEqual({
rx: "1024",
tx: "2048",
});
});
it("calculates bytes per second and rejects counter resets", () => {
expect(counterRate("1000", "2500", 0.5)).toBe(3000);
expect(counterRate("2500", "1000", 0.5)).toBeNull();
});
});
const DARWIN_IFCONFIG = `lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
inet 127.0.0.1 netmask 0xff000000
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:22
inet 192.168.1.5 netmask 0xffffff00 broadcast 192.168.1.255
status: active
en1: flags=8822<BROADCAST,SMART,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:23
status: inactive`;
const DARWIN_NETSTAT = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
lo0 16384 <Link#1> 466472 0 62511610 466472 0 62511610 0
lo0 16384 127 127.0.0.1 466472 - 62511610 466472 - 62511610 -
en0 1500 <Link#4> ac:de:48:00:11:22 123456 0 987654321 65432 0 123456789 0
en0 1500 192.168.1 192.168.1.5 123456 - 987654321 65432 - 123456789 -`;
describe("darwin network parsing", () => {
it("parses interface addresses and up/down state from ifconfig", () => {
const map = parseDarwinIfconfig(DARWIN_IFCONFIG);
expect(map.has("lo0")).toBe(false);
expect(map.get("en0")).toEqual({ ip: "192.168.1.5", state: "UP" });
expect(map.get("en1")).toEqual({ ip: "", state: "DOWN" });
});
it("parses byte counters from the Link-layer netstat -ib row", () => {
const counters = parseDarwinNetstat(DARWIN_NETSTAT);
expect(counters.has("lo0")).toBe(false);
expect(counters.get("en0")).toEqual({
rx: "987654321",
tx: "123456789",
});
});
});
describe("parseWindowsAdapterJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsAdapterJson(
'{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000}',
);
expect(rows).toEqual([
{ name: "Ethernet", ip: "10.0.0.5", state: "UP", rx: "1000", tx: "2000" },
]);
});
it("parses an array of adapters", () => {
const rows = parseWindowsAdapterJson(
'[{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000},' +
'{"name":"Wi-Fi","ip":null,"state":"UP","rx":500,"tx":700}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({
name: "Wi-Fi",
ip: "",
state: "UP",
rx: "500",
tx: "700",
});
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsAdapterJson("")).toEqual([]);
expect(parseWindowsAdapterJson("not json")).toEqual([]);
});
});
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import {
parseSensorsOutput,
parseSysfsThermalOutput,
} from "../../../../../../plugins/host-metrics/backend/widgets/temperature-collector.js";
describe("temperature collectors", () => {
it("parses sysfs thermal zone output", () => {
const result = parseSysfsThermalOutput(
"x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n",
);
expect(result).toEqual([
{ label: "x86_pkg_temp", celsius: 51 },
{ label: "acpitz", celsius: 42 },
]);
});
it("parses lm-sensors temperature lines", () => {
const result = parseSensorsOutput(`
coretemp-isa-0000
Adapter: ISA adapter
Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C)
Core 0: +48.5°C
fan1: 1200 RPM
`);
expect(result).toEqual([
{ label: "coretemp-isa-0000: Package id 0", celsius: 52 },
{ label: "coretemp-isa-0000: Core 0", celsius: 48.5 },
]);
});
it("keeps duplicate sensor names distinct across devices", () => {
const result = parseSensorsOutput(`
nvme-pci-0100
Composite: +41.0°C
nvme-pci-0200
Composite: +52.0°C
`);
expect(result).toEqual([
{ label: "nvme-pci-0100: Composite", celsius: 41 },
{ label: "nvme-pci-0200: Composite", celsius: 52 },
]);
});
});
@@ -1,152 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
const state = vi.hoisted(() => ({
currentUserId: "user-1" as string | undefined,
records: new Map<string, { userId: string; topology: string | null }>(),
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentNetworkTopologyRepository: () => ({
findByUserId: async (userId: string) => state.records.get(userId) ?? null,
upsertForUser: async (userId: string, topology: string) => {
state.records.set(userId, { userId, topology });
},
}),
}));
vi.mock("../../../database/routes/network-topology-dispatch.js", () => ({
registerNetworkTopologyRouter: vi.fn(),
unregisterNetworkTopologyRouter: vi.fn(),
}));
const { router } =
await import("../../../../../plugins/network-topology/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: { body?: Record<string, unknown> }) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: { body?: Record<string, unknown> } = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.records = new Map();
});
describe("GET /network-topology", () => {
it("returns null when the user has no saved topology", async () => {
const res = await invoke("get", "/");
expect(res.jsonBody).toBeNull();
});
it("returns the parsed topology when one exists", async () => {
state.records.set("user-1", {
userId: "user-1",
topology: JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
});
const res = await invoke("get", "/");
expect(res.jsonBody).toEqual({ nodes: [{ id: "host-1" }], edges: [] });
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("get", "/");
expect(res.statusCode).toBe(401);
});
});
describe("POST /network-topology", () => {
it("saves the topology and returns success", async () => {
const res = await invoke("post", "/", {
body: { topology: { nodes: [], edges: [] } },
});
expect(res.jsonBody).toEqual({ success: true });
expect(state.records.get("user-1")?.topology).toBe(
JSON.stringify({ nodes: [], edges: [] }),
);
});
it("rejects a request with no topology", async () => {
const res = await invoke("post", "/", { body: {} });
expect(res.statusCode).toBe(400);
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("post", "/", { body: { topology: {} } });
expect(res.statusCode).toBe(401);
});
});
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/backend/proxmox-import-auth.js";
// The frontend carries its own copy of this decision in
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported bug, so both are held to the same matrix.
describe("resolveProxmoxImportAuth", () => {
it("uses the default credential for key auth when one is configured", () => {
expect(resolveProxmoxImportAuth("key", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: 0,
});
});
it("uses the default credential for password auth when one is configured", () => {
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: 0,
});
});
it("falls back to none when a secret-backed default has no credential", () => {
for (const authType of ["password", "key", "credential"]) {
expect(resolveProxmoxImportAuth(authType, null)).toEqual({
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
});
}
});
it("uses the credential when no default auth type is configured", () => {
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
authType: "credential",
credentialId: 42,
overrideCredentialUsername: 0,
});
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
});
});
it("keeps secretless auth types, with or without a credential", () => {
for (const authType of ["none", "agent", "opkssh", "tailscale", "vault"]) {
for (const credentialId of [null, 7]) {
expect(resolveProxmoxImportAuth(authType, credentialId)).toEqual({
authType,
credentialId: null,
overrideCredentialUsername: 0,
});
}
}
});
});
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "../../../../../plugins/proxmox/backend/proxmox-jump-hosts.js";
describe("Proxmox jump-host persistence", () => {
const jumpHosts = [{ hostId: 7 }, { hostId: 9 }];
it("serializes resolved arrays before inserting a synced guest", () => {
expect(serializeProxmoxJumpHosts(jumpHosts)).toBe(
'[{"hostId":7},{"hostId":9}]',
);
});
it("keeps stored JSON strings stable", () => {
const stored = '[{"hostId":7}]';
expect(serializeProxmoxJumpHosts(stored)).toBe(stored);
expect(parseProxmoxJumpHosts(stored)).toEqual([{ hostId: 7 }]);
});
it("turns missing or malformed values into null", () => {
expect(serializeProxmoxJumpHosts(null)).toBeNull();
expect(serializeProxmoxJumpHosts("invalid")).toBeNull();
});
});
@@ -1,50 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Client } from "ssh2";
const execElevated = vi.fn();
vi.mock("../../../hosts/metrics-shared/exec-elevated.js", () => ({
execElevated: (...args: unknown[]) => execElevated(...args),
}));
const { execPveshCommand } =
await import("../../../../../plugins/proxmox/backend/routes.js");
const client = {} as Client;
describe("Proxmox discovery sudo execution", () => {
beforeEach(() => {
execElevated.mockReset();
});
it("forces pvesh through the stored sudo credentials", async () => {
execElevated.mockResolvedValue({
stdout: '[{"type":"qemu"}]',
stderr: "",
code: 0,
usedSudo: true,
});
await expect(
execPveshCommand(client, "pvesh get /cluster/resources", "secret", 12000),
).resolves.toBe('[{"type":"qemu"}]');
expect(execElevated).toHaveBeenCalledWith(
client,
"pvesh get /cluster/resources",
"secret",
{ forceSudo: true, timeoutMs: 12000 },
);
});
it("surfaces a failed elevated pvesh command", async () => {
execElevated.mockResolvedValue({
stdout: "",
stderr: "pvesh failed",
code: 255,
usedSudo: true,
});
await expect(
execPveshCommand(client, "pvesh get /cluster/resources", "secret"),
).rejects.toThrow("pvesh failed");
});
});
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { withDriveSettings } from "../../../../../plugins/remote-desktop/backend/drive-settings.js";
describe("withDriveSettings", () => {
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
}),
).toEqual({
"enable-drive": true,
"drive-path": "/termix-data/rdp-drive/user-1",
"create-drive-path": true,
});
});
it("falls back to /drive when the environment says nothing", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {}),
).toMatchObject({ "drive-path": "/drive/user-1" });
});
it("leaves a host-chosen drive-path alone", () => {
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
expect(
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
).toBe(config);
});
it("does nothing when the drive is not enabled", () => {
const config = { "enable-drive": false };
expect(withDriveSettings(config, "user-1")).toBe(config);
});
});
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveJumpTunnelEndpoint } from "../../../../../plugins/remote-desktop/backend/jump-tunnel-endpoint.js";
describe("resolveJumpTunnelEndpoint", () => {
it("keeps an in-process guacd tunnel on loopback", () => {
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
bindHost: "127.0.0.1",
advertisedHost: "127.0.0.1",
});
});
it("exposes the tunnel to a separate guacd container", () => {
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix",
});
});
it("supports a custom backend hostname for external guacd", () => {
expect(
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix-backend",
});
});
});
@@ -1,115 +0,0 @@
import net from "net";
import { afterEach, describe, expect, it } from "vitest";
import { createMacosVncCompatibilityProxy } from "../../../../../plugins/remote-desktop/backend/macos-vnc-proxy.js";
const closers: Array<() => void> = [];
afterEach(() => {
for (const close of closers.splice(0)) close();
});
async function listen(server: net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as net.AddressInfo).port);
});
});
}
async function read(socket: net.Socket, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
received = Buffer.concat([received, chunk]);
if (received.length < length) return;
socket.off("data", onData);
resolve(received);
};
socket.on("data", onData);
socket.once("error", reject);
});
}
describe("createMacosVncCompatibilityProxy", () => {
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
let clientBanner = "";
let selectedSecurityType = 0;
const target = net.createServer((socket) => {
socket.write("RFB 003.");
socket.write("889\n");
socket.once("data", (data) => {
clientBanner = data.toString("ascii");
socket.write(Buffer.from([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => {
selectedSecurityType = selection[0];
socket.write("desktop-data");
});
});
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
"RFB 003.008\n",
);
client.write("RFB 003.008\n");
expect([...(await read(client, 2))]).toEqual([1, 2]);
client.write(Buffer.from([2]));
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
expect(clientBanner).toBe("RFB 003.008\n");
expect(selectedSecurityType).toBe(2);
});
it("passes standard RFB negotiation through unchanged", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.008\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
});
it("passes Apple's security types through when VNC auth is unavailable", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.889\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
});
});
@@ -1,84 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../../../../plugins/remote-desktop/backend/rdp-settings.js";
describe("buildRdpSettings", () => {
it("keeps saved RDP settings authoritative over stale advanced config", () => {
expect(
buildRdpSettings({
port: 3390,
domain: "EXAMPLE",
security: "nla",
ignoreCert: true,
guacConfig: {
port: 3389,
domain: "OLD",
security: "rdp",
"ignore-cert": false,
"color-depth": 24,
},
guacdOverrides: { guacdHost: "guacd.internal" },
}),
).toEqual({
port: 3390,
domain: "EXAMPLE",
security: "nla",
"ignore-cert": true,
"color-depth": 24,
guacdHost: "guacd.internal",
});
});
it("preserves an advanced security value when no saved value exists", () => {
expect(
buildRdpSettings({
port: 3389,
ignoreCert: false,
guacConfig: { security: "tls" },
guacdOverrides: {},
}).security,
).toBe("tls");
});
it("uses the prompted domain for prompt-on-connect authentication", () => {
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
});
it("keeps the stored domain for saved authentication", () => {
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
});
});
describe("resolveRdpAuthTypeForConnect", () => {
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "secretless" },
}),
).toBe("none");
});
it("uses a recipient override instead of prompting", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "personal-override" },
}),
).toBe("credential");
});
it("uses authentication shared by the owner", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "credential",
sharedResolution: { source: "owner-shared", authType: "credential" },
}),
).toBe("credential");
});
});
@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import { withRecordingSettings } from "../../../../../plugins/remote-desktop/backend/recording-settings.js";
const PATH = "/app/data/session_recordings/guacamole";
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
describe("withRecordingSettings", () => {
it("takes ownership of the location and filename", () => {
const merged = withRecordingSettings(
{
"recording-path": "/var/lib/termix/recordings",
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
"create-recording-path": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-path": PATH,
"recording-name": NAME,
"create-recording-path": true,
});
});
it("defaults the content flags when the host has no opinion", () => {
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
"recording-exclude-output": false,
"recording-include-keys": true,
});
});
it("keeps the host's content flags, including the falsy ones", () => {
const merged = withRecordingSettings(
{
"recording-exclude-output": true,
"recording-include-keys": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-output": true,
"recording-include-keys": false,
});
});
it("leaves unrelated settings alone", () => {
const merged = withRecordingSettings(
{ "recording-exclude-mouse": true, width: "1920" },
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-mouse": true,
width: "1920",
});
});
it("does not mutate the settings it was given", () => {
const original = { "recording-path": "/tmp/mine" };
withRecordingSettings(original, PATH, NAME);
expect(original).toEqual({ "recording-path": "/tmp/mine" });
});
});
@@ -1,104 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
it("returns the minted token without waiting for a connection that needs that token", async () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression)
.arguments[1] as ts.ArrowFunction;
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
const index = block.tryBlock.statements
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
.filter((i) => i >= 0)
.at(-1)!;
expect(index).toBeGreaterThan(-1);
const tail = block.tryBlock.statements
.slice(index + 1)
.map((s) => s.getText(ast))
.join("\n");
const json = vi.fn();
const wait = vi.fn(() => {
throw new Error("Client has not received the token yet");
});
await runInNewContext(
ts.transpileModule(`(async () => { ${tail} })()`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
waitForGuacdOpen: wait,
token: "minted-token",
termixConnectId: "correlation-id",
req: {},
getRequestMeta: () => ({}),
logAudit: async () => {},
getAuditUsername: async () => "owner",
userId: "owner",
connectionType: "rdp",
hostId: 7,
hostname: "host",
port: 3389,
res: { json },
},
);
expect(wait).not.toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({
token: "minted-token",
termixConnectId: "correlation-id",
guacamoleConnectionId: null,
});
});
it("only reveals the live session ID to its owner", () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/guacamole-server.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"server.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const fn = ast.statements.find(
(s) =>
ts.isFunctionDeclaration(s) &&
s.name?.text === "getGuacSessionByConnectId",
)!;
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
const context = {
exports: {} as Record<string, (id: string, user: string) => unknown>,
guacSessionByConnectId: new Map([["connect-id", info]]),
};
runInNewContext(
ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const lookup = context.exports.getGuacSessionByConnectId;
expect(lookup("connect-id", "owner")).toEqual(info);
expect(lookup("connect-id", "other")).toBeNull();
expect(lookup("pending", "owner")).toBeNull();
});
@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../../../src/backend/utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../../../../plugins/remote-desktop/backend/token-service.js");
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
port: 3389,
"ignore-cert": true,
"disable-auth": true,
});
expect(decrypted?.connection.settings.username).toBeUndefined();
expect(decrypted?.connection.settings.password).toBeUndefined();
});
it("keeps normal RDP credential authentication unchanged", () => {
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
username: "Administrator",
password: "secret",
port: 3389,
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
ownerUserId: "user-1",
protocol: "rdp" as const,
};
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
{},
undefined,
termixMeta,
);
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
});
it("createJoinToken sets connection.join, not connection.type", () => {
const token = tokenService.createJoinToken("guacd-conn-123", true);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-123");
expect(decrypted?.connection.type).toBeUndefined();
expect(decrypted?.connection.readOnly).toBe(true);
});
it("createJoinToken round-trips a read-write join through decryptToken", () => {
const token = tokenService.createJoinToken("guacd-conn-456", false);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-456");
expect(decrypted?.connection.readOnly).toBe(false);
expect(decrypted?.recording).toBeUndefined();
});
});
@@ -1,350 +0,0 @@
import type express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById }));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
connectionStatus: new Map<string, unknown>(),
tunnelConnecting: new Set<string>(),
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../../hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
function response() {
const res = {
statusCode: 0,
body: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
};
return res as unknown as express.Response & {
statusCode: number;
body: { port?: number; error?: string };
};
}
function request(body: unknown, userId: string | undefined = "u1") {
return { body, userId } as unknown as express.Request;
}
const endpoint = (overrides: Record<string, unknown> = {}) => ({
id: "e1",
label: "Proxmox",
scheme: "http",
port: 8006,
path: "/",
access: "tunnel",
render: "embedded",
...overrides,
});
function host(overrides: Record<string, unknown> = {}) {
return {
id: 7,
userId: "u1",
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "pw",
enableWebUi: true,
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
...overrides,
};
}
/** Makes connectSSHTunnel behave like a successful open. */
function tunnelConnectsOn(port: number) {
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.activeTunnelRuntimes.set(config.name, {
bindPort: port,
sourceClient: {},
close: vi.fn(),
});
},
);
}
beforeEach(() => {
manager.activeTunnelRuntimes.clear();
manager.connectionStatus.clear();
manager.tunnelConnecting.clear();
manager.connectSSHTunnel.mockReset();
manager.cleanupTunnelResources.mockClear();
forwardOut.mockClear();
forwardOut.mockResolvedValue({ end: vi.fn() });
resolveHostById.mockReset();
resolveHostById.mockResolvedValue(host());
});
afterEach(() => {
vi.resetModules();
});
describe("POST /tunnel/web-endpoint/open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(403);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
// Reachable via a bulk update that sends only { webUiConfig } while the
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(400);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
res,
);
expect(res.statusCode).toBe(400);
});
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(200);
expect(res.body.port).toBe(41234);
});
it("selects the local-forward path via endpointHost, not targetHost", async () => {
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
// endpointHost === "127.0.0.1". Setting only targetHost would silently
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[0][0];
expect(config.endpointHost).toBe("127.0.0.1");
expect(config.mode).toBe("local");
expect(config.endpointPort).toBe(8006);
// Carries the source SSH identity -- connectSSHTunnel builds its client
// from these, and reads sourceUserId specifically.
expect(config.sourceIP).toBe("10.0.0.5");
expect(config.sourceUserId).toBe("u1");
});
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
"127.0.0.1",
);
manager.activeTunnelRuntimes.clear();
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
}),
}),
);
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[1][0];
expect(config.bindHost).toBe("0.0.0.0");
expect(config.sourcePort).toBe(38080);
});
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
first,
);
const second = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
second,
);
expect(second.body.port).toBe(41234);
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
});
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
// Same id, different port: the live forward now points at the wrong place.
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
}),
);
tunnelConnectsOn(51234);
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
expect(res.body.port).toBe(51234);
});
it("reports the target port being closed rather than returning a dead 200", async () => {
// The likeliest real error: the listener binds fine, but nothing is
// serving on the endpoint's port. Without the probe this returns 200 and
// the user gets a blank frame with no message.
tunnelConnectsOn(41234);
forwardOut.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/connect failed/);
// Nothing half-built is left registered.
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
});
it("surfaces a connection failure reported only over the status map", async () => {
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
// without waitForTunnelSettled this path would read as success.
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.connectionStatus.set(config.name, {
connected: false,
reason: "All authentication methods failed",
});
},
);
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/authentication methods failed/);
});
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
{ hostId: 0, endpointId: "e1" },
]) {
const res = response();
await handleWebEndpointOpen(request(body), res);
expect(res.statusCode).toBe(400);
}
expect(resolveHostById).not.toHaveBeenCalled();
});
});
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
*
* This was caught only by opening a real tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
*/
describe("route registration", () => {
it("registers /open on the router the dispatcher forwards to", async () => {
const routes =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const dispatch =
await import("../../../hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
);
routes.startWebEndpointService();
expect(registered).toHaveLength(1);
const paths = (
routes.router as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
}
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is mounted so the full client path resolves", async () => {
const source = await readFile(
new URL("../../../hosts/tunnel/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
});
});
@@ -1,516 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
type WorkspaceRow = {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
kind: "manual" | "last_session";
isDefault: boolean;
payload: string;
syncId: string;
createdAt: string;
updatedAt: string;
lastUsedAt: string | null;
};
const state = vi.hoisted(() => ({
currentUserId: "user-1",
workspaces: new Map<number, WorkspaceRow>(),
nextId: 1,
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
function findByIdForUser(userId: string, id: number): WorkspaceRow | null {
const row = state.workspaces.get(id);
return row && row.userId === userId ? row : null;
}
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentWorkspaceRepository: () => ({
listByUser: async (userId: string) =>
[...state.workspaces.values()].filter((w) => w.userId === userId),
findById: async (userId: string, id: number) => findByIdForUser(userId, id),
findLastSession: async (userId: string) =>
[...state.workspaces.values()].find(
(w) => w.userId === userId && w.kind === "last_session",
) ?? null,
upsertLastSession: async (userId: string, payload: string) => {
const existing = [...state.workspaces.values()].find(
(w) => w.userId === userId && w.kind === "last_session",
);
if (existing) {
existing.payload = payload;
existing.updatedAt = new Date().toISOString();
return existing;
}
const row: WorkspaceRow = {
id: state.nextId++,
userId,
name: "Last Session",
color: null,
icon: null,
kind: "last_session",
isDefault: false,
payload,
syncId: `sync-${state.nextId}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastUsedAt: null,
};
state.workspaces.set(row.id, row);
return row;
},
create: async (
userId: string,
input: {
name: string;
color?: string | null;
icon?: string | null;
payload: string;
},
) => {
const row: WorkspaceRow = {
id: state.nextId++,
userId,
name: input.name,
color: input.color ?? null,
icon: input.icon ?? null,
kind: "manual",
isDefault: false,
payload: input.payload,
syncId: `sync-${state.nextId}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastUsedAt: null,
};
state.workspaces.set(row.id, row);
return row;
},
update: async (
userId: string,
id: number,
input: { name?: string; color?: string | null; icon?: string | null },
) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
if (input.name !== undefined) row.name = input.name;
if (input.color !== undefined) row.color = input.color;
if (input.icon !== undefined) row.icon = input.icon;
return row;
},
updateContent: async (userId: string, id: number, payload: string) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
row.payload = payload;
return row;
},
setDefault: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
for (const w of state.workspaces.values()) {
if (w.userId === userId && w.kind === "manual") w.isDefault = false;
}
row.isDefault = true;
return row;
},
unsetDefault: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
row.isDefault = false;
return row;
},
touchLastUsed: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (row) row.lastUsedAt = new Date().toISOString();
},
delete: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return false;
state.workspaces.delete(id);
return true;
},
}),
}));
vi.mock("../../../database/routes/workspace-dispatch.js", () => ({
registerWorkspacesRouter: vi.fn(),
unregisterWorkspacesRouter: vi.fn(),
}));
const { router } =
await import("../../../../../plugins/workspaces/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.workspaces = new Map();
state.nextId = 1;
});
describe("GET /", () => {
it("returns the list with a computed tabCount", async () => {
await invoke("post", "/", {
body: {
name: "Test A",
payload: { version: 1, tabs: [{ slotId: "a" }, { slotId: "b" }] },
},
});
const res = await invoke("get", "/");
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toHaveLength(1);
expect(
(res.jsonBody as unknown as { tabCount: number }[])[0].tabCount,
).toBe(2);
});
});
describe("POST /", () => {
it("400s when name is missing", async () => {
const res = await invoke("post", "/", {
body: { payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(400);
});
it("400s when payload has no tabs array", async () => {
const res = await invoke("post", "/", {
body: { name: "Test A", payload: { version: 1 } },
});
expect(res.statusCode).toBe(400);
});
it("200s and creates a manual workspace", async () => {
const res = await invoke("post", "/", {
body: { name: "Test A", payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ name: "Test A", kind: "manual" });
});
});
describe("PATCH /:id", () => {
it("rejects renaming the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("patch", "/:id", {
params: { id: String(id) },
body: { name: "Nope" },
});
expect(res.statusCode).toBe(404);
});
it("renames a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "Old", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("patch", "/:id", {
params: { id: String(id) },
body: { name: "New" },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ name: "New" });
});
it("404s for a nonexistent id", async () => {
const res = await invoke("patch", "/:id", {
params: { id: "999" },
body: { name: "New" },
});
expect(res.statusCode).toBe(404);
});
});
describe("PUT /:id/content", () => {
it("updates payload for a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [{ slotId: "x" }] } },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
});
it("404s on wrong owner", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
state.currentUserId = "user-2";
const res = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(404);
});
});
describe("DELETE /:id", () => {
it("rejects deleting the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
expect(res.statusCode).toBe(404);
});
it("404s for a nonexistent id", async () => {
const res = await invoke("delete", "/:id", { params: { id: "999" } });
expect(res.statusCode).toBe(404);
});
it("deletes a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toEqual({ success: true });
});
});
describe("POST /:id/duplicate", () => {
it("produces a second independent row", async () => {
const created = await invoke("post", "/", {
body: {
name: "A",
payload: { version: 1, tabs: [{ slotId: "a" }] },
},
});
const id = (created.jsonBody as unknown as { id: number }).id;
const dup = await invoke("post", "/:id/duplicate", {
params: { id: String(id) },
body: { name: "A (copy)" },
});
expect(dup.statusCode).toBe(200);
expect(dup.jsonBody).toMatchObject({ name: "A (copy)", tabCount: 1 });
const updateOriginal = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [] } },
});
expect(updateOriginal.jsonBody).toMatchObject({ tabCount: 0 });
const dupId = (dup.jsonBody as unknown as { id: number }).id;
const refetched = await invoke("get", "/");
const dupRow = (
refetched.jsonBody as unknown as { id: number; tabCount: number }[]
).find((w) => w.id === dupId);
expect(dupRow?.tabCount).toBe(1);
});
});
describe("POST /:id/set-default", () => {
it("clears a previous default and sets the new one", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const b = await invoke("post", "/", {
body: { name: "B", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
const bId = (b.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const second = await invoke("post", "/:id/set-default", {
params: { id: String(bId) },
});
expect(second.jsonBody).toMatchObject({ isDefault: true });
const list = await invoke("get", "/");
const aRow = (
list.jsonBody as unknown as { id: number; isDefault: boolean }[]
).find((w) => w.id === aId);
expect(aRow?.isDefault).toBe(false);
});
it("is idempotent when re-called on an already-default row", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const again = await invoke("post", "/:id/set-default", {
params: { id: String(aId) },
});
expect(again.statusCode).toBe(200);
expect(again.jsonBody).toMatchObject({ isDefault: true });
});
});
describe("POST /:id/unset-default", () => {
it("clears isDefault on a manual workspace", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const res = await invoke("post", "/:id/unset-default", {
params: { id: String(aId) },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ isDefault: false });
});
it("rejects unsetting the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("post", "/:id/unset-default", {
params: { id: String(id) },
});
expect(res.statusCode).toBe(404);
});
});
describe("POST /:id/apply", () => {
it("touches lastUsedAt and returns the parsed payload", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [{ slotId: "a" }] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("post", "/:id/apply", {
params: { id: String(id) },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({
payload: { version: 1, tabs: [{ slotId: "a" }] },
});
expect(
(res.jsonBody as unknown as { lastUsedAt: string | null }).lastUsedAt,
).toBeTruthy();
});
});
describe("PUT /last-session and GET /last-session", () => {
it("returns null before any save", async () => {
const res = await invoke("get", "/last-session");
expect(res.jsonBody).toBeNull();
});
it("upserts idempotently - two calls produce one row", async () => {
await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [{ slotId: "a" }] } },
});
const list = await invoke("get", "/");
const lastSessionRows = (
list.jsonBody as unknown as { kind: string }[]
).filter((w) => w.kind === "last_session");
expect(lastSessionRows).toHaveLength(1);
const res = await invoke("get", "/last-session");
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
});
});