mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-15 08:09:16 +00:00
459 lines
13 KiB
TypeScript
459 lines
13 KiB
TypeScript
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",
|
|
notification: expect.objectContaining({
|
|
title: "Ping schedule",
|
|
body: "hello",
|
|
category: "automations.notify",
|
|
audience: { userId: "alice" },
|
|
channelIds: [1],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
// 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,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("alerts", () => {
|
|
const schedule = { kind: "schedule", intervalSeconds: 300 };
|
|
|
|
it("sends a notify step to the owner's inbox when no channel is picked", async () => {
|
|
server = await startServer();
|
|
const id = await create(
|
|
server,
|
|
"Inbox only",
|
|
definition(schedule, [
|
|
{ id: "n", type: "notify", channelIds: [], title: "Heads up" },
|
|
]),
|
|
);
|
|
|
|
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
|
|
|
expect(outcome.body.status).toBe("success");
|
|
expect(server.mock.notifications).toEqual([
|
|
{
|
|
actor: "alice",
|
|
notification: expect.objectContaining({
|
|
title: "Heads up",
|
|
audience: { userId: "alice" },
|
|
channelIds: [],
|
|
link: { tab: "automations" },
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("only describes the alert on a dry run", async () => {
|
|
server = await startServer();
|
|
const id = await create(
|
|
server,
|
|
"Dry",
|
|
definition(schedule, [
|
|
{ id: "n", type: "notify", channelIds: [1], title: "Heads up" },
|
|
]),
|
|
);
|
|
|
|
const outcome = await server.request("POST", `/${id}/run`, {
|
|
body: { dryRun: true },
|
|
});
|
|
|
|
expect(outcome.body.status).toBe("success");
|
|
expect(server.mock.notifications).toEqual([]);
|
|
});
|
|
|
|
it("alerts the owner when a run fails", async () => {
|
|
server = await startServer();
|
|
const id = await create(
|
|
server,
|
|
"Breaks",
|
|
definition(schedule, [{ id: "s", type: "stop", status: "failed" }]),
|
|
);
|
|
|
|
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
|
|
|
expect(outcome.body.status).toBe("failed");
|
|
expect(server.mock.notifications).toEqual([
|
|
{
|
|
actor: "alice",
|
|
notification: expect.objectContaining({
|
|
title: 'Automation "Breaks" failed',
|
|
severity: "warning",
|
|
category: "automations.run_failed",
|
|
audience: { userId: "alice" },
|
|
dedupeKey: `automations.run_failed:${id}`,
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
});
|