mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move automations into a first-party plugin
This commit is contained in:
1 parent
a6f6a0439c
commit
e0f14f2ecf
47 files changed
+384
-125
No files matched your search
@@ -0,0 +1,192 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -283,3 +283,44 @@ describe("bundled fleets plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled automations plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "automations");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has a manifest that passes the real validator", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
readBundledManifest("automations"),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("automations");
|
||||
expect(manifest?.category).toBe("Infrastructure");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("automations"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("automations")).toBe(true);
|
||||
expect(runsInProcess("automations", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"automations",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,9 +39,10 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"automations",
|
||||
"docker",
|
||||
"fleets",
|
||||
"host-metrics",
|
||||
@@ -51,7 +52,7 @@ describe("first-party allowlist", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
@@ -59,6 +60,7 @@ describe("first-party allowlist", () => {
|
||||
expect(isFirstParty("proxmox")).toBe(true);
|
||||
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||
expect(isFirstParty("fleets")).toBe(true);
|
||||
expect(isFirstParty("automations")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -79,6 +81,10 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||
expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
@@ -88,6 +94,8 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("fleets", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -179,6 +187,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("proxmox");
|
||||
});
|
||||
|
||||
it("allows it for automations", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "automations",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("automations");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
Reference in new issue
Block a user