mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user