mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: finish the automations plugin (B17)
This commit is contained in:
1 parent
993a91c899
commit
6b4d1432ab
96 files changed
+25106
-5077
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
NOTIFICATION_CHANNELS_DDL,
|
||||
definition,
|
||||
manifest,
|
||||
pluginDir,
|
||||
startServer,
|
||||
type TestServer,
|
||||
} from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("automations activate", () => {
|
||||
it("provides automations.access and mounts the webhook as public", async () => {
|
||||
server = await startServer();
|
||||
expect(server.mock.services.get("automations.access")).toBeDefined();
|
||||
expect(server.mock.httpRouters).toEqual([{ public: ["/webhook/:token"] }]);
|
||||
});
|
||||
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
|
||||
});
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot notify without notify:send", async () => {
|
||||
server = await startServer();
|
||||
const withoutNotify = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
|
||||
});
|
||||
await expect(
|
||||
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("stops every timer on deactivate", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Tick",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 300 }),
|
||||
},
|
||||
});
|
||||
for (const dispose of [...server.mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
server.mock.disposals.length = 0;
|
||||
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const fired = vi.hoisted(() => [] as unknown[]);
|
||||
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
onDockerEvent: async (event: unknown) => {
|
||||
fired.push(event);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => ({ listAllEnabled: async () => [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
reconcileDockerWatch,
|
||||
resetDockerWatcher,
|
||||
} from "../../src/backend/docker-watcher.js";
|
||||
import {
|
||||
setDockerServices,
|
||||
runDockerAction,
|
||||
} from "../../src/backend/docker.js";
|
||||
|
||||
type Listener = (event: {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: string;
|
||||
}) => void;
|
||||
|
||||
/** A docker.events provider that records who subscribed to what. */
|
||||
function fakeEvents() {
|
||||
const subscriptions = new Map<string, Set<Listener>>();
|
||||
const calls: Array<{ userId?: string; hostId: number }> = [];
|
||||
const services = {
|
||||
provide: () => {},
|
||||
providers: () => [],
|
||||
get: (name: string, options?: { userId?: string }) => {
|
||||
if (name !== "docker.events") return {};
|
||||
return {
|
||||
subscribe: async (hostId: number, listener: Listener) => {
|
||||
calls.push({ userId: options?.userId, hostId });
|
||||
const key = `${options?.userId}:${hostId}`;
|
||||
const set = subscriptions.get(key) ?? new Set<Listener>();
|
||||
set.add(listener);
|
||||
subscriptions.set(key, set);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PluginServices;
|
||||
const emit = (key: string, event: Parameters<Listener>[0]) => {
|
||||
for (const listener of subscriptions.get(key) ?? []) listener(event);
|
||||
};
|
||||
return { services, subscriptions, calls, emit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fired.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetDockerWatcher();
|
||||
setDockerServices(null);
|
||||
});
|
||||
|
||||
describe("docker watcher", () => {
|
||||
it("subscribes each watched host as its owner and fires the trigger", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
|
||||
await reconcileDockerWatch(0, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toEqual([{ userId: "user-1", hostId: 7 }]);
|
||||
|
||||
events.emit("user-1:7", { hostId: 7, container: "web", event: "exited" });
|
||||
await Promise.resolve();
|
||||
expect(fired).toEqual([
|
||||
{ hostId: 7, ownerUserId: "user-1", container: "web", event: "exited" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-subscribes once a minute and drops hosts no longer watched", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
const watched = new Map([[7, "user-1"]]);
|
||||
|
||||
await reconcileDockerWatch(1_000, watched);
|
||||
await reconcileDockerWatch(30_000, watched);
|
||||
expect(events.calls).toHaveLength(1);
|
||||
await reconcileDockerWatch(62_000, watched);
|
||||
expect(events.calls).toHaveLength(2);
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(1);
|
||||
|
||||
await reconcileDockerWatch(63_000, new Map());
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does nothing while the docker plugin is off, then picks it up", async () => {
|
||||
setDockerServices({
|
||||
get: () => ({}),
|
||||
} as unknown as PluginServices);
|
||||
await expect(
|
||||
reconcileDockerWatch(0, new Map([[7, "user-1"]])),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
await reconcileDockerWatch(1, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails a docker step cleanly while the plugin is off", async () => {
|
||||
setDockerServices({ get: () => ({}) } as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The docker plugin is not available",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a docker step through the docker service", async () => {
|
||||
const action = vi.fn(async () => {});
|
||||
setDockerServices({
|
||||
get: (name: string) => (name === "docker.containers" ? { action } : {}),
|
||||
} as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(action).toHaveBeenCalledWith(7, "web", "restart");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { definition, startServer, type TestServer } from "./helpers";
|
||||
|
||||
/**
|
||||
* The engine as activate() wires it: the ctx.schedule tick, the services it
|
||||
* reaches, and what happens when a plugin an automation needs is off.
|
||||
*/
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function create(
|
||||
s: TestServer,
|
||||
name: string,
|
||||
body: ReturnType<typeof definition>,
|
||||
): Promise<number> {
|
||||
const created = await s.request("POST", "/", {
|
||||
body: { name, definition: body },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
return created.body.id;
|
||||
}
|
||||
|
||||
function makeDue(s: TestServer, automationId: number) {
|
||||
s.db.sqlite
|
||||
.prepare(
|
||||
"UPDATE p_automations_schedules SET next_due_at = ? WHERE automation_id = ?",
|
||||
)
|
||||
.run(new Date(Date.now() - 60_000).toISOString(), automationId);
|
||||
}
|
||||
|
||||
describe("scheduled automations", () => {
|
||||
it("fire from the ctx.schedule tick and notify as the owner", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Every five minutes",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "n",
|
||||
type: "notify",
|
||||
channelIds: [1],
|
||||
title: "Ping {{trigger.type}}",
|
||||
body: "hello",
|
||||
},
|
||||
]),
|
||||
);
|
||||
makeDue(server, id);
|
||||
|
||||
// The tick is registered through ctx.schedule, never a bare timer.
|
||||
expect(server.mock.scheduled.filter((job) => !job.stopped).length).toBe(2);
|
||||
await server.mock.runScheduled();
|
||||
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
expect(server.mock.notifications).toEqual([
|
||||
{
|
||||
actor: "alice",
|
||||
channelIds: [1],
|
||||
notification: expect.objectContaining({
|
||||
title: "Ping schedule",
|
||||
body: "hello",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
// The next due time moved forward, so the next tick does not re-run it.
|
||||
const due = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
|
||||
)
|
||||
.get(id) as { next_due_at: string };
|
||||
expect(new Date(due.next_due_at).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("are skipped with a reason while the docker plugin is off, and the engine keeps going", async () => {
|
||||
server = await startServer();
|
||||
const needsDocker = await create(
|
||||
server,
|
||||
"Restart app",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const plain = await create(
|
||||
server,
|
||||
"Plain",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }),
|
||||
);
|
||||
makeDue(server, needsDocker);
|
||||
makeDue(server, plain);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
|
||||
expect(await server.waitForRun(needsDocker)).toEqual({
|
||||
status: "skipped",
|
||||
error: "Needs the docker plugin",
|
||||
});
|
||||
expect(await server.waitForRun(plain)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
|
||||
// A manual run says the same thing instead of throwing.
|
||||
const manual = await server.request("POST", `/${needsDocker}/run`, {
|
||||
body: {},
|
||||
});
|
||||
expect(manual.status).toBe(200);
|
||||
expect(manual.body).toMatchObject({
|
||||
status: "skipped",
|
||||
error: "Needs the docker plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("run once docker is back", async () => {
|
||||
const actions: unknown[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"docker.containers": {
|
||||
action: async (...args: unknown[]) => {
|
||||
actions.push(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Restart app",
|
||||
definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
// Host 1 does not resolve here, so the step has no target, but the
|
||||
// automation is no longer skipped for a missing plugin.
|
||||
expect(outcome.body.status).not.toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automations.access", () => {
|
||||
it("lists, reads, creates disabled and runs as the caller", async () => {
|
||||
server = await startServer();
|
||||
const service = server.service;
|
||||
|
||||
const created = await server.mock.ctx.asUser("alice", () =>
|
||||
service.create({
|
||||
name: "From the assistant",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 600 }),
|
||||
}),
|
||||
);
|
||||
const listed = await server.mock.ctx.asUser("alice", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: created.id,
|
||||
enabled: false,
|
||||
triggerKind: "schedule",
|
||||
missingPlugins: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
const one = await server.mock.ctx.asUser("alice", () =>
|
||||
service.get(created.id),
|
||||
);
|
||||
expect(one?.definition?.trigger.kind).toBe("schedule");
|
||||
expect(
|
||||
await server.mock.ctx.asUser("bob", () => service.get(created.id)),
|
||||
).toBeNull();
|
||||
|
||||
const outcome = await server.mock.ctx.asUser("alice", () =>
|
||||
service.run(created.id, { dryRun: true }),
|
||||
);
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
|
||||
it("refuses an invalid definition", async () => {
|
||||
server = await startServer();
|
||||
await expect(
|
||||
server.mock.ctx.asUser("alice", () =>
|
||||
server!.service.create({ name: "x", definition: { steps: [] } }),
|
||||
),
|
||||
).rejects.toThrow(/trigger/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional steps", () => {
|
||||
it("fail the step cleanly while the tunnels plugin is off", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Tunnel",
|
||||
definition({ kind: "webhook" }, [
|
||||
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body).toMatchObject({
|
||||
status: "skipped",
|
||||
error: "Needs the tunnels plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("start a tunnel through tunnels.access", async () => {
|
||||
const started: string[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"tunnels.access": {
|
||||
start: async (name: string) => {
|
||||
started.push(name);
|
||||
},
|
||||
stop: async () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Tunnel",
|
||||
definition({ kind: "webhook" }, [
|
||||
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
|
||||
]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(started).toEqual(["db"]);
|
||||
});
|
||||
|
||||
it("wake a host through the wake-on-lan service", async () => {
|
||||
const woken: number[] = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"wake-on-lan.send": {
|
||||
wake: async (hostId: number) => {
|
||||
woken.push(hostId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Wake",
|
||||
definition({ kind: "webhook" }, [{ id: "w", type: "wol", hostId: 2 }]),
|
||||
);
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(woken).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchers", () => {
|
||||
it("subscribe to docker.events as the owner and fire docker_event triggers", async () => {
|
||||
let listener: ((event: unknown) => void) | null = null;
|
||||
const subscribedAs: Array<string | undefined> = [];
|
||||
server = await startServer({
|
||||
services: {
|
||||
"docker.events": {
|
||||
subscribe: async (_hostId: number, fn: (event: unknown) => void) => {
|
||||
listener = fn;
|
||||
subscribedAs.push(server?.mock.ctx.currentActor());
|
||||
return () => {
|
||||
listener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"Container died",
|
||||
definition({
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
container: "app",
|
||||
event: "exited",
|
||||
cooldownMinutes: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
expect(subscribedAs).toEqual(["alice"]);
|
||||
expect(listener).not.toBeNull();
|
||||
|
||||
listener!({ hostId: 1, container: "app", event: "exited" });
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keep headless metrics viewers registered as the owner", async () => {
|
||||
const registered: Array<[number, string | undefined]> = [];
|
||||
let beats = 0;
|
||||
server = await startServer({
|
||||
services: {
|
||||
"host-metrics.viewers": {
|
||||
register: async (hostId: number) => {
|
||||
registered.push([hostId, server?.mock.ctx.currentActor()]);
|
||||
return { viewerSessionId: `v${hostId}` };
|
||||
},
|
||||
heartbeat: () => {
|
||||
beats++;
|
||||
return true;
|
||||
},
|
||||
unregister: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await create(
|
||||
server,
|
||||
"CPU",
|
||||
definition({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "hosts", hostIds: [1, 2] },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
}),
|
||||
);
|
||||
|
||||
await server.mock.runScheduled();
|
||||
expect(registered).toEqual([
|
||||
[1, "alice"],
|
||||
[2, "alice"],
|
||||
]);
|
||||
const before = beats;
|
||||
await server.mock.runScheduled();
|
||||
// Each tick heartbeats both viewers and registers nothing new.
|
||||
expect(beats).toBeGreaterThan(before);
|
||||
expect((beats - before) % 2).toBe(0);
|
||||
expect(registered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fire a metric trigger from a host-metrics snapshot event", async () => {
|
||||
server = await startServer({
|
||||
services: {
|
||||
"host-metrics.viewers": {
|
||||
register: async () => ({ viewerSessionId: "v" }),
|
||||
heartbeat: () => true,
|
||||
unregister: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const id = await create(
|
||||
server,
|
||||
"CPU",
|
||||
definition({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
}),
|
||||
);
|
||||
|
||||
server.mock.ctx.events.emit("plugin.host-metrics.snapshot", {
|
||||
hostId: 1,
|
||||
ownerUserId: "alice",
|
||||
metrics: { cpu: { percent: 97 } },
|
||||
});
|
||||
expect(await server.waitForRun(id)).toEqual({
|
||||
status: "success",
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
import type { AutomationDefinition, Step } from "../../src/types.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,
|
||||
* The engine reaches the database through its repository and the outside
|
||||
* world through the step executors, so both are faked here. What is under
|
||||
* test is the run loop itself: ordering, branching, error policy,
|
||||
* concurrency, recursion and dry-run.
|
||||
*/
|
||||
|
||||
@@ -50,15 +49,7 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
let hosts: PluginHostSummary[] = [];
|
||||
|
||||
const executeStep = vi.fn();
|
||||
vi.mock("../../src/backend/actions/index.js", () => ({
|
||||
@@ -67,6 +58,18 @@ vi.mock("../../src/backend/actions/index.js", () => ({
|
||||
|
||||
const { AutomationEngine } = await import("../../src/backend/engine.js");
|
||||
|
||||
// Every provider is running unless a test says otherwise.
|
||||
const deps = { available: () => true };
|
||||
|
||||
let current: InstanceType<typeof AutomationEngine>;
|
||||
function getEngine() {
|
||||
return current;
|
||||
}
|
||||
function freshEngine() {
|
||||
const { ctx } = createFakeContext({ pluginId: "automations", hosts });
|
||||
current = new AutomationEngine(ctx, repository as never, deps as never);
|
||||
}
|
||||
|
||||
function defineAutomation(
|
||||
steps: Step[],
|
||||
overrides: Partial<FakeAutomation> = {},
|
||||
@@ -102,23 +105,30 @@ beforeEach(() => {
|
||||
nextRunId = 1;
|
||||
nextStepRowId = 1;
|
||||
vi.clearAllMocks();
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
hosts = [];
|
||||
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
||||
// The singleton carries in-flight state between tests.
|
||||
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
||||
freshEngine();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
hosts = [
|
||||
{
|
||||
id: 11,
|
||||
userId: "user-1",
|
||||
name: "Proxmox Node",
|
||||
ip: "10.0.0.11",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
];
|
||||
freshEngine();
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "metric_threshold",
|
||||
triggerHostId: 11,
|
||||
@@ -143,7 +153,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -161,7 +171,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -178,7 +188,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -193,7 +203,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -209,7 +219,7 @@ describe("AutomationEngine.run", () => {
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -231,7 +241,7 @@ describe("AutomationEngine.run", () => {
|
||||
vars: { x: "1" },
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -254,7 +264,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -274,7 +284,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -296,7 +306,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: { value: 95 },
|
||||
@@ -316,7 +326,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "after", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -335,7 +345,7 @@ describe("AutomationEngine.run", () => {
|
||||
halt: { status: "success" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -351,7 +361,7 @@ describe("AutomationEngine.run", () => {
|
||||
halt: { status: "failed" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -365,7 +375,7 @@ describe("AutomationEngine.run", () => {
|
||||
step({ id: "nested", type: "run_automation", automationId: 1 }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -389,7 +399,7 @@ describe("AutomationEngine.run", () => {
|
||||
{ id: 2 },
|
||||
);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -402,7 +412,7 @@ describe("AutomationEngine.run", () => {
|
||||
it("refuses to nest deeper than the maximum depth", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
depth: 99,
|
||||
@@ -428,7 +438,7 @@ describe("AutomationEngine.run", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const engine = getEngine();
|
||||
const first = engine.run({ automationId: 1, triggerType: "manual" });
|
||||
// Let the first run register itself as in flight.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
@@ -461,7 +471,7 @@ describe("AutomationEngine.run", () => {
|
||||
|
||||
// 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 engine = getEngine();
|
||||
const [first, second] = await Promise.all([
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
@@ -478,7 +488,7 @@ describe("AutomationEngine.run", () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
repository.createRun.mockRejectedValueOnce(new Error("db down"));
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const engine = getEngine();
|
||||
const failed = await engine.run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
@@ -497,7 +507,7 @@ describe("AutomationEngine.run", () => {
|
||||
it("propagates the dry-run flag to executors", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -509,7 +519,7 @@ describe("AutomationEngine.run", () => {
|
||||
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({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
dryRun: true,
|
||||
@@ -520,7 +530,7 @@ describe("AutomationEngine.run", () => {
|
||||
});
|
||||
|
||||
it("fails cleanly when the automation is missing", async () => {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 404,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -539,7 +549,7 @@ describe("AutomationEngine.run", () => {
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -551,7 +561,7 @@ describe("AutomationEngine.run", () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
executeStep.mockRejectedValueOnce(new Error("connection reset"));
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -568,7 +578,7 @@ describe("AutomationEngine.run", () => {
|
||||
output: "x".repeat(40_000),
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
@@ -591,7 +601,7 @@ describe("AutomationEngine.run", () => {
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
const outcome = await getEngine().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listAutomationWatchedHosts = vi.fn();
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||
}));
|
||||
|
||||
const {
|
||||
automationSessionId,
|
||||
reconcileHeadlessViewers,
|
||||
releaseHeadlessViewers,
|
||||
setViewerRegistry,
|
||||
} = await import("../../src/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,205 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginHostSummary,
|
||||
PluginNotificationChannel,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { AutomationsAccessV1 } from "../../src/backend/service.js";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
];
|
||||
|
||||
/** Core's notification_channels, which automation_channels points at. */
|
||||
export const NOTIFICATION_CHANNELS_DDL = `
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export function host(id: number, userId = "alice"): PluginHostSummary {
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
};
|
||||
}
|
||||
|
||||
/** A snippets.access provider, always there since snippets is a hard dependency. */
|
||||
export function fakeSnippets() {
|
||||
return {
|
||||
list: async () => [{ id: 5, name: "Restart nginx", isNote: false }],
|
||||
get: async (id: number) =>
|
||||
id === 5
|
||||
? {
|
||||
id: 5,
|
||||
name: "Restart nginx",
|
||||
content: "systemctl restart nginx",
|
||||
isNote: false,
|
||||
}
|
||||
: null,
|
||||
resolveCommand: async (id: number) =>
|
||||
id === 5 ? "systemctl restart nginx" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
service: AutomationsAccessV1;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string | null; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
/** Waits until a run for the automation reaches a final status. */
|
||||
waitForRun: (
|
||||
automationId: number,
|
||||
) => Promise<{ status: string; error: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it: JSON parsed and the acting user set from the
|
||||
* request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
services?: Record<string, object>;
|
||||
hosts?: PluginHostSummary[];
|
||||
channels?: PluginNotificationChannel[];
|
||||
before?: (db: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
|
||||
options.before?.(sqlite);
|
||||
},
|
||||
});
|
||||
for (const user of ["alice", "bob"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
const channels = options.channels ?? [
|
||||
{ id: 1, name: "ops", type: "webhook", enabled: true },
|
||||
];
|
||||
for (const channel of channels) {
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, 'alice', ?, ?, '{}', ?)",
|
||||
)
|
||||
.run(channel.id, channel.name, channel.type, channel.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
hosts: options.hosts ?? [host(1), host(2)],
|
||||
notificationChannels: channels,
|
||||
services: { "snippets.access": fakeSnippets(), ...options.services },
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
service: mock.services.get("automations.access") as AutomationsAccessV1,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(user ? { "x-test-user": user } : {}),
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async waitForRun(automationId) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const row = db.sqlite
|
||||
.prepare(
|
||||
"SELECT status, error FROM p_automations_runs WHERE automation_id = ? AND status != 'running' ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.get(automationId) as
|
||||
{ status: string; error: string | null } | undefined;
|
||||
if (row) return row;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`No finished run for automation ${automationId}`);
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A definition with one step, for tests that only care about the trigger. */
|
||||
export function definition(
|
||||
trigger: Record<string, unknown>,
|
||||
steps: Array<Record<string, unknown>> = [
|
||||
{ id: "v", type: "set_var", name: "done", value: "yes" },
|
||||
],
|
||||
) {
|
||||
return { version: 1, trigger, steps };
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { NOTIFICATION_CHANNELS_DDL, pluginDir } from "./helpers";
|
||||
|
||||
// The six tables as core's SQLite bootstrap created them before 2.9.0, with
|
||||
// the indexes performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
definition TEXT NOT NULL,
|
||||
definition_version INTEGER NOT NULL DEFAULT 1,
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
|
||||
max_run_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
last_run_at TEXT,
|
||||
last_run_status TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE automation_trigger_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
state_key TEXT NOT NULL,
|
||||
breach_started_at TEXT,
|
||||
last_fired_at TEXT,
|
||||
last_value REAL,
|
||||
last_observed_state TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE automation_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
cron TEXT,
|
||||
interval_seconds INTEGER,
|
||||
timezone TEXT,
|
||||
next_due_at TEXT,
|
||||
last_tick_at TEXT
|
||||
);
|
||||
CREATE TABLE automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_context TEXT,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
error TEXT,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
parent_run_id INTEGER
|
||||
);
|
||||
CREATE TABLE automation_run_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
|
||||
step_index INTEGER NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TEXT,
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
truncated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE automation_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_automations_user ON automations (user_id, enabled);
|
||||
CREATE UNIQUE INDEX idx_automation_trigger_state_key ON automation_trigger_state (automation_id, state_key);
|
||||
CREATE UNIQUE INDEX idx_automation_schedules_automation ON automation_schedules (automation_id);
|
||||
CREATE INDEX idx_automation_schedules_due ON automation_schedules (next_due_at);
|
||||
CREATE INDEX idx_automation_runs_automation ON automation_runs (automation_id, started_at);
|
||||
CREATE INDEX idx_automation_runs_user ON automation_runs (user_id, started_at);
|
||||
CREATE INDEX idx_automation_run_steps_run ON automation_run_steps (run_id, step_index);
|
||||
CREATE UNIQUE INDEX idx_automation_channels_pair ON automation_channels (automation_id, channel_id);
|
||||
`;
|
||||
|
||||
const SEED = `
|
||||
INSERT INTO users (id, username) VALUES ('alice', 'alice');
|
||||
INSERT INTO notification_channels (id, user_id, name, type, config) VALUES (4, 'alice', 'ops', 'webhook', '{}');
|
||||
INSERT INTO automations (id, user_id, name, definition) VALUES (7, 'alice', 'Disk', '{"version":1,"trigger":{"kind":"webhook","tokenHash":"x"},"steps":[]}');
|
||||
INSERT INTO automation_trigger_state (automation_id, state_key, last_fired_at) VALUES (7, '1:/data', '2026-01-01T00:00:00Z');
|
||||
INSERT INTO automation_schedules (automation_id, interval_seconds, next_due_at) VALUES (7, 300, '2026-01-01T00:05:00Z');
|
||||
INSERT INTO automation_runs (id, automation_id, user_id, trigger_type, status) VALUES (3, 7, 'alice', 'manual', 'success');
|
||||
INSERT INTO automation_run_steps (run_id, step_index, step_id, step_type, status) VALUES (3, 0, 'a', 'notify', 'success');
|
||||
INSERT INTO automation_channels (automation_id, channel_id) VALUES (7, 4);
|
||||
`;
|
||||
|
||||
const ADOPTED = [
|
||||
"p_automations_automations",
|
||||
"p_automations_trigger_state",
|
||||
"p_automations_schedules",
|
||||
"p_automations_runs",
|
||||
"p_automations_run_steps",
|
||||
"p_automations_channels",
|
||||
];
|
||||
|
||||
const LEGACY = [
|
||||
"automations",
|
||||
"automation_trigger_state",
|
||||
"automation_schedules",
|
||||
"automation_runs",
|
||||
"automation_run_steps",
|
||||
"automation_channels",
|
||||
];
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
function tables(target: TestDb): string[] {
|
||||
return (
|
||||
target.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all() as Array<{ name: string }>
|
||||
).map((row) => row.name);
|
||||
}
|
||||
|
||||
function indexes(target: TestDb, table: string): string[] {
|
||||
return (
|
||||
target.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as Array<{ name: string }>
|
||||
)
|
||||
.map((row) => row.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const count = (target: TestDb, table: string) =>
|
||||
(
|
||||
target.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
|
||||
describe("adopting the automation tables", () => {
|
||||
it("keeps every row, index and cascade when the legacy tables exist", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite.exec(SEED);
|
||||
},
|
||||
});
|
||||
|
||||
const names = tables(db);
|
||||
for (const table of ADOPTED) expect(names).toContain(table);
|
||||
for (const table of LEGACY) expect(names).not.toContain(table);
|
||||
|
||||
for (const table of ADOPTED) expect(count(db, table)).toBe(1);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT name FROM p_automations_automations WHERE id = 7")
|
||||
.get(),
|
||||
).toEqual({ name: "Disk" });
|
||||
|
||||
// Only one copy of each legacy index, never a second one.
|
||||
expect(indexes(db, "p_automations_trigger_state")).toEqual([
|
||||
"idx_automation_trigger_state_key",
|
||||
]);
|
||||
expect(indexes(db, "p_automations_runs")).toEqual([
|
||||
"idx_automation_runs_automation",
|
||||
"idx_automation_runs_user",
|
||||
]);
|
||||
|
||||
// The foreign keys followed the rename: deleting the automation still
|
||||
// takes its state, schedule, runs, steps and channel links with it.
|
||||
db.sqlite
|
||||
.prepare("DELETE FROM p_automations_automations WHERE id = 7")
|
||||
.run();
|
||||
for (const table of ADOPTED) expect(count(db, table)).toBe(0);
|
||||
});
|
||||
|
||||
it("creates the same tables on a fresh install", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
|
||||
});
|
||||
|
||||
const names = tables(db);
|
||||
for (const table of ADOPTED) expect(names).toContain(table);
|
||||
for (const table of LEGACY) expect(names).not.toContain(table);
|
||||
expect(indexes(db, "p_automations_schedules")).toEqual([
|
||||
"idx_automation_schedules_automation",
|
||||
"idx_automation_schedules_due",
|
||||
]);
|
||||
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO users (id, username) VALUES ('alice', 'alice');
|
||||
INSERT INTO p_automations_automations (id, user_id, name, definition) VALUES (1, 'alice', 'A', '{}');
|
||||
INSERT INTO p_automations_runs (id, automation_id, user_id, trigger_type, status) VALUES (1, 1, 'alice', 'manual', 'success');
|
||||
INSERT INTO p_automations_run_steps (run_id, step_index, step_id, step_type, status) VALUES (1, 0, 'a', 'notify', 'success');
|
||||
`);
|
||||
// Deleting the user cascades all the way down.
|
||||
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
|
||||
expect(count(db, "p_automations_automations")).toBe(0);
|
||||
expect(count(db, "p_automations_run_steps")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const automationFetch = vi.fn();
|
||||
|
||||
vi.mock("../../src/backend/http.js", () => ({
|
||||
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||
}));
|
||||
|
||||
const { sendAutomationNotification } =
|
||||
await import("../../src/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,513 +1,231 @@
|
||||
import crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { definition, startServer, type TestServer } from "./helpers";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
let server: TestServer | null = null;
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
rows: [] as Array<Record<string, unknown>>,
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../../src/backend/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("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/backend/database/routes/automation-dispatch.js",
|
||||
() => ({
|
||||
registerAutomationsRouter: vi.fn(),
|
||||
unregisterAutomationsRouter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
requirePermission:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/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("../../../../src/backend/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("../../src/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" });
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("creates an automation from a valid definition", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Disk watch", definition: definition() },
|
||||
});
|
||||
const schedule = definition({ kind: "schedule", intervalSeconds: 300 });
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.jsonBody?.name).toBe("Disk watch");
|
||||
describe("automation routes", () => {
|
||||
it("creates, lists, updates and deletes an automation", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: " Nightly ", definition: schedule, channels: [1, 99] },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
name: "Nightly",
|
||||
enabled: 1,
|
||||
// 99 is not one of alice's channels, so it is not linked.
|
||||
channels: [1],
|
||||
missingPlugins: [],
|
||||
});
|
||||
const id = created.body.id;
|
||||
|
||||
// A schedule trigger registers its next due time.
|
||||
const scheduleRow = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
|
||||
)
|
||||
.get(id) as { next_due_at: string | null };
|
||||
expect(scheduleRow.next_due_at).toBeTruthy();
|
||||
|
||||
const listed = await server.request("GET", "/");
|
||||
expect(listed.body.map((row: { id: number }) => row.id)).toEqual([id]);
|
||||
expect(listed.body[0].definition.trigger.kind).toBe("schedule");
|
||||
|
||||
const updated = await server.request("PUT", `/${id}`, {
|
||||
body: { enabled: false, name: "Nightly job" },
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toMatchObject({ enabled: 0, name: "Nightly job" });
|
||||
|
||||
expect((await server.request("DELETE", `/${id}`)).status).toBe(200);
|
||||
expect((await server.request("GET", `/${id}`)).status).toBe(404);
|
||||
// The schedule goes with it.
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_automations_schedules")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("requires a name", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { definition: definition() },
|
||||
it("never shows one user's automations to another", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Mine", definition: schedule },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/name/i);
|
||||
|
||||
expect((await server.request("GET", "/", { user: "bob" })).body).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", `/${created.body.id}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("DELETE", `/${created.body.id}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown trigger kind", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
it("rejects an invalid definition", async () => {
|
||||
server = await startServer();
|
||||
const response = await server.request("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({
|
||||
definition: {
|
||||
trigger: { kind: "schedule", intervalSeconds: 5 },
|
||||
} as Partial<AutomationDefinition>),
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toMatch(/60 seconds/);
|
||||
});
|
||||
|
||||
it("registers a schedule for a schedule trigger", async () => {
|
||||
await invoke("post", "/", {
|
||||
it("marks an automation that needs a plugin that is off", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "Nightly",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", cron: "0 2 * * *" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
name: "Restart app",
|
||||
definition: definition({ kind: "schedule", intervalSeconds: 300 }, [
|
||||
{
|
||||
id: "d",
|
||||
type: "docker",
|
||||
action: "restart",
|
||||
container: "app",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(repository.upsertSchedule).toHaveBeenCalled();
|
||||
expect(created.body.missingPlugins).toEqual(["docker"]);
|
||||
});
|
||||
|
||||
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>),
|
||||
it("offers editor options and the running providers", async () => {
|
||||
server = await startServer({
|
||||
services: {
|
||||
"fleets.access": { list: async () => [{ id: 3, name: "web" }] },
|
||||
},
|
||||
});
|
||||
const response = await server.request("GET", "/editor-options");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
hosts: [
|
||||
{ id: 1, name: "host-1" },
|
||||
{ id: 2, name: "host-2" },
|
||||
],
|
||||
snippets: [{ id: 5, name: "Restart nginx" }],
|
||||
fleets: [{ id: 3, name: "web" }],
|
||||
channels: [{ id: 1, name: "ops" }],
|
||||
});
|
||||
expect(response.body.providers).toMatchObject({
|
||||
snippets: true,
|
||||
fleets: true,
|
||||
docker: false,
|
||||
"host-metrics": false,
|
||||
"wake-on-lan": false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const token = res.jsonBody?.webhookToken as string;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
it("returns a webhook token once and runs the automation from it", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Hook", definition: definition({ kind: "webhook" }) },
|
||||
});
|
||||
const token = created.body.webhookToken as string;
|
||||
expect(token).toHaveLength(64);
|
||||
expect(created.body.definition.trigger.tokenHash).toBe("");
|
||||
|
||||
const stored = JSON.parse(state.rows[0].definition as string);
|
||||
expect(stored.trigger.tokenHash).not.toBe(token);
|
||||
expect(stored.trigger.tokenHash).toBe(
|
||||
const stored = server.db.sqlite
|
||||
.prepare("SELECT definition FROM p_automations_automations WHERE id = ?")
|
||||
.get(created.body.id) as { definition: string };
|
||||
expect(JSON.parse(stored.definition).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 },
|
||||
const fired = await server.request("POST", `/webhook/${token}`, {
|
||||
user: null,
|
||||
body: { hello: "world" },
|
||||
});
|
||||
expect(fired.status).toBe(202);
|
||||
expect(fired.body.status).toBe("success");
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
|
||||
);
|
||||
const wrong = await server.request("POST", `/webhook/${"0".repeat(64)}`, {
|
||||
user: null,
|
||||
body: {},
|
||||
});
|
||||
expect(wrong.status).toBe(404);
|
||||
});
|
||||
|
||||
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) },
|
||||
it("runs now, records the steps and lists the run", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: { name: "Now", definition: schedule },
|
||||
});
|
||||
|
||||
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" },
|
||||
const outcome = await server.request("POST", `/${created.body.id}/run`, {
|
||||
body: { dryRun: true },
|
||||
});
|
||||
expect(outcome.body.status).toBe("success");
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
const runs = await server.request("GET", "/runs/history");
|
||||
expect(runs.body[0]).toMatchObject({
|
||||
automation_id: created.body.id,
|
||||
automation_name: "Now",
|
||||
status: "success",
|
||||
dry_run: 1,
|
||||
trigger_type: "manual",
|
||||
});
|
||||
const steps = await server.request("GET", `/runs/${runs.body[0].id}/steps`);
|
||||
expect(steps.body).toMatchObject([
|
||||
{ step_type: "set_var", status: "success" },
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/runs/${runs.body[0].id}/steps`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions", () => {
|
||||
const routes: Array<[string, string, string]> = [
|
||||
["GET", "/", "view"],
|
||||
["GET", "/editor-options", "view"],
|
||||
["GET", "/runs/history", "view"],
|
||||
["GET", "/runs/1/steps", "view"],
|
||||
["GET", "/1", "view"],
|
||||
["POST", "/", "create"],
|
||||
["PUT", "/1", "edit"],
|
||||
["DELETE", "/1", "delete"],
|
||||
["POST", "/1/run", "run"],
|
||||
];
|
||||
|
||||
it.each(routes)(
|
||||
"%s %s answers 403 without automations.%s",
|
||||
async (method, path) => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const response = await server.request(method, path, { body: {} });
|
||||
expect(response.status).toBe(403);
|
||||
},
|
||||
);
|
||||
|
||||
it("serves the webhook without a session", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const response = await server.request(
|
||||
"POST",
|
||||
`/webhook/${"a".repeat(64)}`,
|
||||
{ user: null, body: {} },
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Trigger,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import type { AutomationDefinition, Trigger } from "../../src/types.js";
|
||||
import { createTriggers } from "../../src/backend/triggers.js";
|
||||
|
||||
/**
|
||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
||||
@@ -42,16 +40,14 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
const triggers = await import("../../src/backend/triggers.js");
|
||||
const log = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
const triggers = createTriggers(repository as never, { run }, log);
|
||||
|
||||
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||
@@ -415,7 +411,7 @@ describe("onDockerEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutomationWatchedHosts", () => {
|
||||
describe("watchedHosts", () => {
|
||||
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
@@ -426,7 +422,7 @@ describe("listAutomationWatchedHosts", () => {
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
const watched = await triggers.listAutomationWatchedHosts();
|
||||
const watched = await triggers.watchedHosts("metric_threshold");
|
||||
|
||||
expect([...watched.keys()].sort()).toEqual([3, 4]);
|
||||
});
|
||||
@@ -439,6 +435,6 @@ describe("listAutomationWatchedHosts", () => {
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
|
||||
expect((await triggers.watchedHosts("metric_threshold")).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentNotificationChannelRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
const { setTunnelServices } = await import("../../src/backend/tunnels.js");
|
||||
const { executeStep } = await import("../../src/backend/actions/index.js");
|
||||
|
||||
afterEach(() => {
|
||||
setTunnelServices(null);
|
||||
});
|
||||
|
||||
function context(dryRun = false) {
|
||||
return {
|
||||
userId: "user-1",
|
||||
automationId: 1,
|
||||
runId: 1,
|
||||
dryRun,
|
||||
template: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const step = (action: "connect" | "disconnect") =>
|
||||
({
|
||||
id: "s1",
|
||||
type: "tunnel",
|
||||
action,
|
||||
tunnelName: "7::0::web::8080::db::5432",
|
||||
}) as never;
|
||||
|
||||
describe("the tunnel step", () => {
|
||||
it("starts and stops the tunnel through tunnels.access as the automation's owner", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
const tunnels = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
fake.ctx.services.provide("tunnels.access", tunnels);
|
||||
const get = vi.spyOn(fake.ctx.services, "get");
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const connected = await executeStep(step("connect"), context());
|
||||
expect(connected.success).toBe(true);
|
||||
expect(tunnels.start).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
expect(get).toHaveBeenCalledWith("tunnels.access", { userId: "user-1" });
|
||||
|
||||
const stopped = await executeStep(step("disconnect"), context());
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(tunnels.stop).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
});
|
||||
|
||||
it("reports the tunnel plugin's own error", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
fake.ctx.services.provide("tunnels.access", {
|
||||
start: vi.fn(async () => {
|
||||
throw new Error('Tunnel "x" is not configured');
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
});
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const result = await executeStep(step("connect"), context());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error ?? result.output).toMatch(/not configured/);
|
||||
});
|
||||
|
||||
it("fails the step cleanly while the tunnels plugin is off", async () => {
|
||||
// No provider: the optional dependency is missing.
|
||||
setTunnelServices(
|
||||
createFakeContext({ pluginId: "automations" }).ctx.services,
|
||||
);
|
||||
const withoutProvider = await executeStep(step("connect"), context());
|
||||
expect(withoutProvider.success).toBe(false);
|
||||
expect(withoutProvider.error ?? withoutProvider.output).toMatch(
|
||||
/tunnels plugin is not available/,
|
||||
);
|
||||
|
||||
setTunnelServices(null);
|
||||
const inactive = await executeStep(step("disconnect"), context());
|
||||
expect(inactive.success).toBe(false);
|
||||
});
|
||||
|
||||
it("only describes what it would do on a dry run", async () => {
|
||||
const result = await executeStep(step("connect"), context(true));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toMatch(/Would connect tunnel/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user