From 23b1bd1ffebecd4f42339598bad802c505a5f793 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 01:53:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=204.4=20=E2=80=94=20comando?= =?UTF-8?q?=20webhooks=20com=20CRUD,=20eventos=20e=20dispatch=20de=20teste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/cli/commands/webhooks.mjs | 196 +++++++++++++++++++ tests/unit/cli-webhooks-commands.test.ts | 230 +++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 bin/cli/commands/webhooks.mjs create mode 100644 tests/unit/cli-webhooks-commands.test.ts diff --git a/bin/cli/commands/webhooks.mjs b/bin/cli/commands/webhooks.mjs new file mode 100644 index 0000000000..71a93a083e --- /dev/null +++ b/bin/cli/commands/webhooks.mjs @@ -0,0 +1,196 @@ +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +const EVENT_TYPES = [ + "request.completed", + "request.failed", + "rate_limit.exceeded", + "budget.exceeded", + "quota.reset", + "provider.down", + "provider.up", + "combo.switched", + "circuit.opened", + "circuit.closed", + "skill.executed", + "memory.added", + "audit.created", +]; + +function truncate(v, len = 40) { + if (v == null) return "-"; + const s = String(v); + return s.length > len ? s.slice(0, len - 1) + "…" : s; +} + +function fmtTs(v) { + if (!v) return "-"; + try { + return new Date(v).toLocaleString(); + } catch { + return String(v); + } +} + +function maskSecret(v) { + if (!v) return "-"; + return "***"; +} + +const webhookSchema = [ + { key: "id", header: "ID", width: 22 }, + { key: "url", header: "URL", width: 40, formatter: truncate }, + { + key: "events", + header: "Events", + formatter: (v) => (Array.isArray(v) ? v.join(", ") : String(v ?? "-")), + }, + { key: "enabled", header: "Enabled", formatter: (v) => (v ? "✓" : "✗") }, + { key: "secret", header: "Secret", formatter: maskSecret }, + { key: "lastDelivery", header: "Last Delivery", formatter: fmtTs }, + { key: "lastStatus", header: "Last Status", width: 10 }, +]; + +function parseHeader(kv) { + const eq = kv.indexOf("="); + if (eq === -1) return { name: kv, value: "" }; + return { name: kv.slice(0, eq), value: kv.slice(eq + 1) }; +} + +async function confirm(q) { + return new Promise((resolve) => { + process.stdout.write(`${q} (yes/no) `); + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (c) => resolve(c.toString().trim().toLowerCase().startsWith("y"))); + }); +} + +export async function runWebhooksList(opts, cmd) { + const res = await apiFetch("/api/webhooks"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.items ?? data, cmd.optsWithGlobals(), webhookSchema); +} + +export async function runWebhooksGet(id, opts, cmd) { + const res = await apiFetch(`/api/webhooks/${id}`); + if (!res.ok) { + process.stderr.write(`Not found: ${id}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals(), webhookSchema); +} + +export async function runWebhooksAdd(opts, cmd) { + const body = { + url: opts.url, + events: opts.events, + ...(opts.secret ? { secret: opts.secret } : {}), + headers: opts.header ?? [], + enabled: opts.enabled !== false, + }; + const res = await apiFetch("/api/webhooks", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals(), webhookSchema); +} + +export async function runWebhooksUpdate(id, opts, cmd) { + const body = {}; + if (opts.url !== undefined) body.url = opts.url; + if (opts.events !== undefined) body.events = opts.events; + if (opts.secret !== undefined) body.secret = opts.secret; + if (opts.enabled !== undefined) body.enabled = opts.enabled; + if (opts.header?.length) body.headers = opts.header.map(parseHeader); + const res = await apiFetch(`/api/webhooks/${id}`, { method: "PUT", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals(), webhookSchema); +} + +export async function runWebhooksRemove(id, opts, cmd) { + if (!opts.yes) { + const ok = await confirm(`Delete webhook ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/webhooks/${id}`, { method: "DELETE" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Removed\n"); +} + +export async function runWebhooksTest(id, opts, cmd) { + const body = { event: opts.event ?? "request.completed" }; + const res = await apiFetch(`/api/webhooks/${id}/test`, { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data, cmd.optsWithGlobals()); +} + +export function registerWebhooks(program) { + const webhooks = program.command("webhooks").description(t("webhooks.description")); + + webhooks + .command("events") + .description(t("webhooks.events.description")) + .action(async (opts, cmd) => { + emit( + EVENT_TYPES.map((e) => ({ event: e })), + cmd.optsWithGlobals() + ); + }); + + webhooks.command("list").description(t("webhooks.list.description")).action(runWebhooksList); + + webhooks.command("get ").description(t("webhooks.get.description")).action(runWebhooksGet); + + webhooks + .command("add") + .description(t("webhooks.add.description")) + .requiredOption("--url ", t("webhooks.add.url")) + .requiredOption("--events ", t("webhooks.add.events"), (v) => v.split(",")) + .option("--secret ", t("webhooks.add.secret")) + .option( + "--header ", + t("webhooks.add.header"), + (v, prev) => [...(prev ?? []), parseHeader(v)], + [] + ) + .option("--no-enabled", t("webhooks.add.no_enabled")) + .action(runWebhooksAdd); + + webhooks + .command("update ") + .description(t("webhooks.update.description")) + .option("--url ", t("webhooks.add.url")) + .option("--events ", t("webhooks.add.events"), (v) => v.split(",")) + .option("--secret ", t("webhooks.add.secret")) + .option("--header ", t("webhooks.add.header"), (v, prev) => [...(prev ?? []), v], []) + .option("--enabled ", t("webhooks.update.enabled"), (v) => v === "true") + .action(runWebhooksUpdate); + + webhooks + .command("remove ") + .description(t("webhooks.remove.description")) + .option("--yes", t("webhooks.remove.yes")) + .action(runWebhooksRemove); + + webhooks + .command("test ") + .description(t("webhooks.test.description")) + .option("--event ", t("webhooks.test.event"), "request.completed") + .action(runWebhooksTest); +} diff --git a/tests/unit/cli-webhooks-commands.test.ts b/tests/unit/cli-webhooks-commands.test.ts new file mode 100644 index 0000000000..e036c0f70f --- /dev/null +++ b/tests/unit/cli-webhooks-commands.test.ts @@ -0,0 +1,230 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const WEBHOOK = { + id: "wh-001", + url: "https://example.com/hook", + events: ["request.completed", "request.failed"], + enabled: true, + secret: "s3cr3t", + lastDelivery: "2026-05-14T10:00:00Z", + lastStatus: 200, +}; + +const WEBHOOKS = [ + WEBHOOK, + { ...WEBHOOK, id: "wh-002", url: "https://other.io/hook", enabled: false }, +]; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("runWebhooksList retorna lista de webhooks", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + assert.ok(url.includes("/api/webhooks")); + return Promise.resolve(makeResp({ items: WEBHOOKS })); + }) as any; + + const { runWebhooksList } = await import("../../bin/cli/commands/webhooks.mjs"); + const out = await captureStdout(() => runWebhooksList({}, makeCmd() as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed.length, 2); +}); + +test("runWebhooksGet busca webhook por id", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp(WEBHOOK)); + }) as any; + + const { runWebhooksGet } = await import("../../bin/cli/commands/webhooks.mjs"); + const out = await captureStdout(() => runWebhooksGet("wh-001", {}, makeCmd() as any)); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/webhooks/wh-001")); + const parsed = JSON.parse(out); + assert.equal(parsed.id, "wh-001"); +}); + +test("runWebhooksAdd envia url, events e secret", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp(WEBHOOK)); + }) as any; + + const { runWebhooksAdd } = await import("../../bin/cli/commands/webhooks.mjs"); + await captureStdout(() => + runWebhooksAdd( + { + url: "https://example.com/hook", + events: ["request.completed"], + secret: "s3cr3t", + header: [], + enabled: true, + }, + makeCmd() as any + ) + ); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.url, "https://example.com/hook"); + assert.deepEqual(capturedBody.events, ["request.completed"]); + assert.equal(capturedBody.secret, "s3cr3t"); + assert.equal(capturedBody.enabled, true); +}); + +test("runWebhooksAdd não expõe secret na saída (mascarado)", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string) => { + return Promise.resolve(makeResp(WEBHOOK)); + }) as any; + + const { runWebhooksAdd } = await import("../../bin/cli/commands/webhooks.mjs"); + const out = await captureStdout(() => + runWebhooksAdd( + { + url: "https://example.com/hook", + events: ["request.completed"], + secret: "s3cr3t", + header: [], + enabled: true, + }, + makeCmd("table") as any + ) + ); + + globalThis.fetch = origFetch; + assert.ok(!out.includes("s3cr3t"), "secret não deve aparecer no output"); + assert.ok(out.includes("***") || !out.includes("s3cr3t")); +}); + +test("runWebhooksUpdate envia apenas campos fornecidos", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp(WEBHOOK)); + }) as any; + + const { runWebhooksUpdate } = await import("../../bin/cli/commands/webhooks.mjs"); + await captureStdout(() => runWebhooksUpdate("wh-001", { enabled: false }, makeCmd() as any)); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/webhooks/wh-001")); + assert.equal(capturedBody.enabled, false); + assert.equal(capturedBody.url, undefined); +}); + +test("runWebhooksRemove com --yes chama DELETE", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp({}, 204)); + }) as any; + + const out = await captureStdout(async () => { + const { runWebhooksRemove } = await import("../../bin/cli/commands/webhooks.mjs"); + await runWebhooksRemove("wh-001", { yes: true }, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/webhooks/wh-001")); + assert.equal(capturedMethod, "DELETE"); + assert.ok(out.includes("Removed")); +}); + +test("runWebhooksTest envia event no body", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ delivered: true, status: 200 })); + }) as any; + + const { runWebhooksTest } = await import("../../bin/cli/commands/webhooks.mjs"); + await captureStdout(() => + runWebhooksTest("wh-001", { event: "budget.exceeded" }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/webhooks/wh-001/test")); + assert.equal(capturedBody.event, "budget.exceeded"); +}); + +test("webhooks events lista todos tipos de evento conhecidos", async () => { + const EVENT_TYPES = [ + "request.completed", + "request.failed", + "rate_limit.exceeded", + "budget.exceeded", + "quota.reset", + "provider.down", + "provider.up", + "combo.switched", + "circuit.opened", + "circuit.closed", + "skill.executed", + "memory.added", + "audit.created", + ]; + + const out = await captureStdout(async () => { + const cmd = makeCmd(); + const { emit } = await import("../../bin/cli/output.mjs"); + emit( + EVENT_TYPES.map((e) => ({ event: e })), + cmd.optsWithGlobals() + ); + }); + + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.ok(parsed.length >= 13); + assert.ok(parsed.some((e: any) => e.event === "request.completed")); + assert.ok(parsed.some((e: any) => e.event === "budget.exceeded")); +});