ts6-botflow/engine.test.mjs
Bxio 1be8a45dde
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
Expand flow editor with TeamSpeak events, permissions and execution controls
2026-10-10 20:38:31 +01:00

688 lines
22 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { createStudio } from "./studio.mjs";
import { nodeTypes, validateGraph } from "./flow-catalog.mjs";
import { executeStep, simulatedQuery, exampleClients } from "./flow-steps.mjs";
import { evaluate, interpolate, timeValues } from "./flow-values.mjs";
import { compileCron } from "./flow-schedule.mjs";
import { templates } from "./flow-templates.mjs";
const node = (type, config = {}, id = type) => ({
id,
type,
label: type,
x: 50,
y: 50,
config: { ...structuredClone(nodeTypes[type].defaults), ...config },
});
const graph = (...nodes) => ({
nodes,
edges: nodes
.slice(1)
.map((n, i) => ({ from: nodes[i].id, to: n.id, port: "out" })),
});
function fixture(options = {}) {
const directory = mkdtempSync(join(tmpdir(), "botflow-engine-")),
db = new DatabaseSync(join(directory, "test.sqlite"));
const calls = [],
states = [];
const studio = createStudio(db, directory, {
scheduler: false,
transport: async (c, command, params) => {
calls.push({ command, params });
return simulatedQuery(command);
},
eventFactory: (c, onEvent, onState, settings) => {
states.push({ c, onEvent, onState, settings });
onState({ status: "connected", fingerprint: "a".repeat(64) });
return { close() {}, ready: true };
},
...options,
});
async function invoke(
path,
method = "GET",
body = {},
role = "Dono",
extraHeaders = {},
) {
const req = Readable.from(
method === "GET" ? [] : [Buffer.from(JSON.stringify(body))],
);
req.method = method;
req.headers = { origin: "http://localhost:3080", ...extraHeaders };
let result;
const url = new URL(path, "http://localhost:3080");
if (
!(await studio.handleWebhook(
req,
url,
(status, data) => (result = { status, data }),
))
)
await studio.route(
req,
url,
{ role, name: "Tester" },
(status, data) => (result = { status, data }),
);
return result;
}
const connection = async (extras = {}) =>
(
await invoke("/api/connections", "POST", {
name: "Test",
url: "http://teamspeak6:10080",
serverId: 1,
apiKey: "test-key",
...extras,
})
).data;
const bot = async (g, connectionId = "", extras = {}) => {
const created = (
await invoke("/api/bots", "POST", { import: { name: "Test", graph: g } })
).data;
const updated = await invoke(`/api/bots/${created.id}`, "PUT", {
connectionId,
...extras,
});
assert.equal(updated.status, 200, updated.data.error);
return updated.data;
};
return {
db,
directory,
studio,
calls,
states,
invoke,
connection,
bot,
async close() {
await studio.close();
db.close();
rmSync(directory, { recursive: true, force: true });
},
};
}
async function until(fn) {
for (let i = 0; i < 100; i++) {
const result = await fn();
if (result) return result;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("Timed out waiting for execution");
}
test("safe expressions, filters and time support calculations without JavaScript access", () => {
const context = {
server: { total: "8", query: "2" },
clock: timeValues(new Date("2026-10-10T12:34:56Z"), "UTC"),
text: "olá",
temp: { list: [1, 2] },
};
assert.equal(
evaluate("round((server.total - server.query) / 4)", context),
2,
);
assert.equal(
evaluate('server.total > 4 && contains(text, "ol")', context),
true,
);
assert.equal(evaluate("false && missing > 0", context), false);
assert.equal(evaluate("true || missing > 0", context), true);
assert.equal(
interpolate(
"{{server.total > 4 || false}} · {{text | upper}} · {{time.time}}:{{time.seconds}}",
context,
),
"true · OLÁ · 12:34:56",
);
assert.equal(interpolate('{{contains("a|b", "|")}}', context), "true");
assert.equal(interpolate("{{temp.list | json}}", context), "[1,2]");
assert.throws(() =>
evaluate('constructor.constructor("return process")()', context),
);
assert.throws(() => evaluate("process.exit()", context));
assert.throws(() => evaluate("1 / 0", context), /zero/);
assert.throws(() => evaluate("x = 2", context));
assert.equal(interpolate("{{server.constructor}}", context), "");
});
test("cron evaluates seconds, ranges, day/week OR and timezone", () => {
const date = new Date("2026-10-10T12:34:56Z");
assert.equal(compileCron("* * * * * *", "UTC")(date), true);
assert.equal(compileCron("* * * * *", "UTC")(date), false);
assert.equal(
compileCron("* * * * *", "UTC")(new Date("2026-10-10T12:34:00Z")),
true,
);
assert.equal(compileCron("*/2 30-35 12 10 10 0", "UTC")(date), true);
assert.equal(compileCron("56 34 13 * * *", "Europe/Lisbon")(date), true);
assert.throws(() => compileCron("61 * * * * *"), /intervalo/);
assert.throws(() => compileCron("* * *"), /campos/);
assert.throws(() => compileCron("* * * * * *", "Invalid/Timezone"));
});
test("every advertised action executes in simulation without network calls", async () => {
let network = 0;
for (const [name, type] of Object.entries(nodeTypes)) {
if (type.trigger || name === "foreach") continue;
const context = {
server: simulatedQuery("serverinfo")[0],
event: {
clid: 1,
cid: 21,
client_database_id: 10,
client_nickname: "Pessoa",
},
clients: structuredClone(exampleClients),
count: 3,
temp: {},
variables: {},
clock: timeValues(),
time: "12:34:56",
text: "Texto",
top: "Top",
};
const n = node(name, {
banId: 1,
url:
name === "discord"
? "https://discord.com/api/webhooks/1/test"
: "http://localhost",
groupId: 9,
});
const result = await executeStep(n, context, {
simulation: true,
call: () => network++,
httpTransport: () => network++,
channelMessage: () => network++,
persist: () => network++,
});
assert.equal(typeof result.message, "string", name);
}
assert.equal(network, 0);
for (const template of templates)
assert.doesNotThrow(() => validateGraph(template.graph), template.name);
});
test("TeamSpeak actions distinguish session IDs and database IDs; permissions use correct scope", async () => {
const calls = [],
context = {
temp: {},
event: { clid: "23", client_database_id: "456" },
variables: {},
clock: timeValues(),
};
const run = async (type, config = {}) =>
executeStep(node(type, config), context, {
call: async (command, params) => {
calls.push({ command, params });
return [{ sgid: "9" }];
},
persist() {},
signal: new AbortController().signal,
});
await run("move", { channelId: 21 });
await run("groupAdd", { groupId: 9 });
await run("clientPermissionSet", { value: "-1", skip: "1" });
await run("channelPermissionSet", { channelId: 21 });
await run("servergroupPermissionSet", {
groupId: 9,
negated: "1",
skip: "1",
});
await run("channelclientPermissionRemove", { channelId: 21 });
assert.deepEqual(calls[0], {
command: "clientmove",
params: { clid: 23, cid: 21 },
});
assert.deepEqual(calls[1], {
command: "servergroupaddclient",
params: { cldbid: 456, sgid: 9 },
});
assert.equal(calls[2].params.cldbid, 456);
assert.equal(calls[2].params.permvalue, -1);
assert.equal(calls[2].params.permskip, "1");
assert.equal(calls[2].params.permnegated, undefined);
assert.equal(calls[3].params.permskip, undefined);
assert.equal(calls[3].params.permnegated, undefined);
assert.equal(calls[4].params.permnegated, "1");
assert.equal(calls[4].params.permskip, "1");
assert.deepEqual(calls[5], {
command: "channelclientdelperm",
params: { cldbid: 456, cid: 21, permsid: "i_client_talk_power" },
});
await run("groups");
assert.ok(
Array.isArray(context.temp.groups),
"single-result lists remain arrays",
);
await assert.rejects(() => run("move", { clientId: "not-an-id" }), /inteiro/);
});
test("foreach executes Each sequentially and Done once; simulations never persist variables", async () => {
const f = fixture();
try {
const g = graph(
node("manual"),
node("clients"),
node("foreach"),
node("variable", {
name: "total",
scope: "persistent",
operation: "increment",
value: "1",
}),
node("template", { text: "Total {{variables.total}}" }, "done"),
);
g.edges = [
{ from: "manual", to: "clients", port: "out" },
{ from: "clients", to: "foreach", port: "out" },
{ from: "foreach", to: "variable", port: "each" },
{ from: "foreach", to: "done", port: "done" },
];
const c = await f.connection(),
b = await f.bot(g, c.id, { variables: { total: 0 } });
const simulated = (await f.invoke(`/api/bots/${b.id}/simulate`, "POST"))
.data;
assert.equal(simulated.output, "Total 3");
assert.equal(
simulated.steps.filter((s) => s.type === "variable").length,
3,
);
assert.equal(f.calls.length, 0);
assert.equal(
(await f.invoke("/api/dashboard")).data.bots.find((x) => x.id === b.id)
.variables.total,
0,
);
const real = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data;
assert.equal(real.status, "success");
assert.equal(real.output, "Total 3");
assert.equal(
(await f.invoke("/api/dashboard")).data.bots.find((x) => x.id === b.id)
.variables.total,
3,
);
} finally {
await f.close();
}
});
test("retry and error branches retain failures in the execution timeline", async () => {
let attempts = 0;
const f = fixture({
transport: async () => {
attempts++;
throw new Error("Query refused");
},
});
try {
const g = graph(
node("manual"),
node("serverinfo", { retries: 1 }),
node("template", { text: "Tratado: {{temp.error.message}}" }),
);
g.edges[1].port = "error";
const c = await f.connection(),
b = await f.bot(g, c.id),
result = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data;
assert.equal(result.status, "warning");
assert.equal(attempts, 2);
assert.equal(result.steps[1].attempts, 2);
assert.equal(result.steps[1].status, "error");
assert.equal(result.output, "Tratado: Query refused");
} finally {
await f.close();
}
});
test("cancellation interrupts waits, blocks overlap, and allows subsequent runs", async () => {
const f = fixture();
try {
const c = await f.connection(),
b = await f.bot(
graph(node("manual"), node("delay", { seconds: 100 })),
c.id,
);
const pending = f.invoke(`/api/bots/${b.id}/run`, "POST");
await until(() => f.studio.running.has(b.id));
assert.equal((await f.invoke(`/api/bots/${b.id}/run`, "POST")).status, 400);
assert.equal(
(await f.invoke(`/api/bots/${b.id}/cancel`, "POST")).status,
200,
);
assert.equal((await pending).data.status, "cancelled");
assert.equal(f.studio.running.size, 0);
assert.equal(
(await f.invoke(`/api/bots/${b.id}/simulate`, "POST")).data.status,
"success",
);
} finally {
await f.close();
}
});
test("event filters, group-restricted chat commands, multiple triggers and encrypted secrets", async () => {
const f = fixture();
try {
const c = await f.connection({
eventsEnabled: true,
sshUsername: "query",
sshPassword: "ssh-secret",
});
const g = graph(
node("event", { filters: { client_type: "0" } }),
node("command", { command: "!info", allowedGroup: "9" }),
node("template", {
text: "{{event.client_nickname}} · {{event.command_args}}",
}),
);
g.edges = [
{ from: "event", to: "template", port: "out" },
{ from: "command", to: "template", port: "out" },
];
const b = await f.bot(g, c.id, { enabled: true });
assert.equal(f.states.length, 1);
assert.equal(f.states[0].c.sshPassword, "ssh-secret");
const stored = f.db
.prepare("SELECT data FROM studio_connections")
.get().data;
assert.ok(!stored.includes("ssh-secret"));
assert.match(stored, /a{64}/);
await f.studio.receive(c.id, "notifycliententerview", {
clid: "1",
client_type: "1",
client_nickname: "Query",
});
assert.equal((await f.invoke("/api/dashboard")).data.executions.length, 0);
await f.studio.receive(c.id, "notifycliententerview", {
clid: "1",
client_type: "0",
client_nickname: "Pessoa",
});
await until(async () =>
(await f.invoke("/api/dashboard")).data.executions.some(
(e) => e.status === "success",
),
);
await f.studio.receive(c.id, "notifytextmessage", {
invokerid: "1",
invokername: "Pessoa",
msg: "!info teste",
__listenerChannel: "0",
});
const runs = await until(async () => {
const list = (await f.invoke("/api/dashboard")).data.executions;
return list.filter((e) => e.status === "success").length === 2 && list;
});
assert.equal(runs[0].output, "Pessoa A · teste");
await f.invoke(`/api/bots/${b.id}`, "PUT", {
graph: {
...g,
nodes: g.nodes.map((n) =>
n.type === "command"
? { ...n, config: { ...n.config, allowedGroup: "999" } }
: n,
),
},
});
await f.studio.receive(c.id, "notifytextmessage", {
invokerid: "1",
msg: "!info",
__listenerChannel: "0",
});
assert.equal((await f.invoke("/api/dashboard")).data.executions.length, 2);
assert.equal(
(
await f.invoke(`/api/bots/${b.id}/simulate`, "POST", {
triggerId: "missing",
})
).status,
400,
);
} finally {
await f.close();
}
});
test("webhooks authenticate before enqueueing; exports remove secrets and imports remain disabled", async () => {
const f = fixture();
try {
const c = await f.connection(),
secret = "secret-for-test-12345",
g = graph(
node("webhook", { secret, filters: { kind: "update" } }),
node("http", { headers: { Authorization: "secret-header" } }),
node("template", { text: "{{event.webhook_body.name}}" }),
);
const b = await f.bot(g, c.id, { enabled: true });
const stored = f.db
.prepare("SELECT data FROM studio_bots WHERE id=?")
.get(b.id).data;
assert.ok(!stored.includes(secret));
assert.ok(!stored.includes("secret-header"));
const reader = (
await f.invoke("/api/dashboard", "GET", {}, "Leitor")
).data.bots.find((x) => x.id === b.id);
assert.equal(reader.graph.nodes[0].config.secret, undefined);
assert.equal(reader.graph.nodes[1].config.headers, undefined);
assert.equal(
(
await f.invoke(
`/api/hooks/${b.id}`,
"POST",
{ kind: "update" },
{},
{ "x-webhook-secret": "bad" },
)
).status,
401,
);
const exported = (await f.invoke(`/api/bots/${b.id}/export`, "POST")).data;
assert.equal(exported.format, "ts6-botflow/v1");
assert.equal(exported.bot.graph.nodes[0].config.secret, undefined);
assert.equal(exported.bot.connectionId, undefined);
const imported = (
await f.invoke("/api/bots", "POST", { import: exported.bot })
).data;
assert.equal(imported.enabled, false);
assert.equal(imported.connectionId, "");
assert.ok(imported.graph.nodes[0].config.secret.length >= 16);
await f.invoke(`/api/bots/${b.id}`, "PUT", {
graph: graph(
node("webhook", { secret }),
node("template", { text: "{{event.webhook_body.name}}" }),
),
});
assert.equal(
(
await f.invoke(
`/api/hooks/${b.id}`,
"POST",
{ name: "Webhook" },
"Dono",
{ "x-webhook-secret": secret },
)
).status,
202,
);
const result = await until(async () =>
(await f.invoke("/api/dashboard")).data.executions.find(
(e) => e.status === "success",
),
);
assert.equal(result.output, "Webhook");
const copy = (await f.invoke(`/api/bots/${b.id}/duplicate`, "POST")).data;
assert.notEqual(copy.id, b.id);
assert.equal(copy.enabled, false);
assert.equal(
(
await f.invoke("/api/bots", "POST", {
import: {
graph: graph(node("manual")),
variables: { constructor: "bad" },
},
})
).status,
400,
);
} finally {
await f.close();
}
});
test("channel messages serialize a temporary Query move and restore the original channel on failure", async () => {
const calls = [];
const f = fixture({
transport: async (c, command, params) => {
calls.push({ command, params });
if (command === "whoami")
return [{ client_id: "50", client_channel_id: "7" }];
if (command === "sendtextmessage") throw new Error("Message refused");
return [];
},
});
try {
const c = await f.connection(),
b = await f.bot(
graph(
node("manual"),
node("message", { mode: "channel", target: 21, text: "Test" }),
),
c.id,
);
const result = (await f.invoke(`/api/bots/${b.id}/run`, "POST")).data;
assert.equal(result.status, "error");
assert.deepEqual(
calls.map((c) => c.command),
["whoami", "clientmove", "sendtextmessage", "clientmove"],
);
assert.deepEqual(calls.at(-1).params, { clid: 50, cid: "7" });
} finally {
await f.close();
}
});
test("validation rejects cycles and preserves legitimate multiple-trigger branches", () => {
const g = graph(node("manual"), node("log"), node("delay"));
g.edges.push({ from: "delay", to: "log", port: "out" });
assert.throws(() => validateGraph(g), /ciclo/);
assert.doesNotThrow(() =>
validateGraph({
nodes: [node("manual"), node("timer"), node("log")],
edges: [
{ from: "manual", to: "log", port: "out" },
{ from: "timer", to: "log", port: "out" },
],
}),
);
assert.throws(
() => validateGraph(graph(node("manual"), node("timer"))),
/inválida/,
);
});
test("legacy graphs, encrypted keys and sessions survive reopening the database", async () => {
const f = fixture();
let next;
try {
const c = await f.connection(),
b = await f.bot(templates[0].graph, c.id);
const legacy = JSON.parse(
f.db.prepare("SELECT data FROM studio_bots WHERE id=?").get(b.id).data,
);
for (const n of legacy.graph.nodes) {
delete n.secureConfig;
if (["clients", "serverinfo"].includes(n.type)) n.config = {};
}
delete legacy.timeoutSeconds;
f.db
.prepare("UPDATE studio_bots SET data=? WHERE id=?")
.run(JSON.stringify(legacy), b.id);
const oldConnection = JSON.parse(
f.db.prepare("SELECT data FROM studio_connections WHERE id=?").get(c.id)
.data,
);
for (const key of Object.keys(oldConnection))
if (!["id", "name", "url", "serverId", "apiKey"].includes(key))
delete oldConnection[key];
f.db
.prepare("UPDATE studio_connections SET data=? WHERE id=?")
.run(JSON.stringify(oldConnection), c.id);
f.db.exec(
"CREATE TABLE sessions(id TEXT PRIMARY KEY,user TEXT,expires INTEGER)",
);
f.db
.prepare("INSERT INTO sessions VALUES(?,?,?)")
.run("session-fixture", "owner", Date.now() + 10000);
await f.studio.close();
next = createStudio(f.db, f.directory, {
scheduler: false,
transport: async (connection, command) => {
assert.equal(connection.apiKey, "test-key");
return simulatedQuery(command);
},
});
assert.equal(f.db.prepare("SELECT user FROM sessions").get().user, "owner");
assert.equal(
f.db.prepare("SELECT count(*) AS count FROM studio_bots").get().count,
2,
);
const req = Readable.from([Buffer.from("{}")]);
req.method = "POST";
req.headers = { origin: "http://localhost:3080" };
let response;
await next.route(
req,
new URL(`/api/bots/${b.id}/run`, "http://localhost:3080"),
{ role: "Dono", name: "Test" },
(status, data) => (response = { status, data }),
);
assert.equal(response.status, 200);
assert.equal(response.data.status, "success");
assert.equal(response.data.steps.length, 7);
assert.match(response.data.output, /Online: 3 \/ 32/);
} finally {
await next?.close();
await f.close();
}
});
test("typed variables support lists and shutdown waits for cancellation before closing storage", async () => {
const f = fixture();
try {
const g = graph(
node("manual"),
node("variable", { name: "list", valueType: "json", value: "[1,2,3]" }),
node("template", { text: "{{length(temp.list)}}" }),
);
const c = await f.connection(),
b = await f.bot(g, c.id),
simulation = (await f.invoke(`/api/bots/${b.id}/simulate`, "POST")).data;
assert.equal(simulation.output, "3");
await f.invoke(`/api/bots/${b.id}`, "PUT", {
graph: graph(node("manual"), node("delay", { seconds: 100 })),
});
const pending = f.invoke(`/api/bots/${b.id}/run`, "POST");
await until(() => f.studio.running.has(b.id));
await f.studio.close();
assert.equal((await pending).data.status, "cancelled");
assert.equal(f.studio.running.size, 0);
assert.equal(
f.db.prepare("SELECT count(*) AS count FROM studio_executions").get()
.count,
2,
);
} finally {
await f.close();
}
});