diff --git a/package-lock.json b/package-lock.json index 6c8c0218..e4bbfecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sorokeep", - "version": "0.1.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sorokeep", - "version": "0.1.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "@aws-sdk/client-secrets-manager": "^3.1075.0", @@ -38,9 +38,6 @@ "tsx": "^4.21.0", "typescript": "^5.7.3", "vitest": "^3.0.7" - }, - "optionalDependencies": { - "@rolldown/binding-linux-x64-gnu": "^1.1.3" } }, "node_modules/@ampproject/remapping": { @@ -1257,22 +1254,6 @@ "node": ">=14" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1846,7 +1827,6 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -2213,7 +2193,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3038,7 +3017,6 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -3312,7 +3290,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3863,7 +3840,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4720,7 +4696,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5767,7 +5742,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5843,7 +5817,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5924,7 +5897,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -6023,7 +5995,6 @@ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", @@ -6266,7 +6237,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -6307,7 +6277,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index 03444a29..d210b3b7 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -19,6 +19,21 @@ import { registerBuiltinChannels } from "../alerts/builtins.js"; registerBuiltinChannels(); +function buildTestEvent(contractId: string, thresholdLedgers: number) { + return buildAlertEvent({ + type: "threshold_crossed", + contractId, + contractName: null, + network: "testnet", + entryKeyXdr: "TEST_ENTRY_KEY", + entryType: "instance", + entryLabel: "test-entry", + configuredLedgers: thresholdLedgers, + remainingTTL: Math.floor(thresholdLedgers * 0.5), + firedAtLedger: 0, + }); +} + export function registerAlertsCommand(program: Command): void { const alerts = program .command("alerts") @@ -184,6 +199,31 @@ export function registerAlertsCommand(program: Command): void { console.log(); }); + // ── alerts channels ──────────────────────────────────────────────── + alerts + .command("channels") + .description("List all registered alert channel plugins") + .action(() => { + const channels = listAlertChannels(); + + if (channels.length === 0) { + console.log(chalk.yellow("No alert channels are registered.")); + return; + } + + console.log(); + console.log(chalk.bold(" Registered Alert Channels")); + console.log(); + for (const channel of channels) { + console.log( + ` Name: ${chalk.cyan(channel.name.padEnd(12))} | ` + + `Target: ${chalk.yellow(channel.targetOption.padEnd(10))} | ` + + `Signing: ${chalk.green(channel.supportsSigning ? "yes" : "no")}` + ); + } + console.log(); + }); + // ── alerts remove ────────────────────────────────────────────────── alerts .command("remove") @@ -220,18 +260,7 @@ export function registerAlertsCommand(program: Command): void { process.exit(1); } - const testEvent = buildAlertEvent({ - type: "threshold_crossed", - contractId: config.contract_id, - contractName: null, - network: "testnet", - entryKeyXdr: "TEST_ENTRY_KEY", - entryType: "instance", - entryLabel: "test-entry", - configuredLedgers: config.threshold_ledgers, - remainingTTL: Math.floor(config.threshold_ledgers * 0.5), - firedAtLedger: 0, - }); + const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers); console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`); @@ -250,6 +279,66 @@ export function registerAlertsCommand(program: Command): void { } }); + // ── alerts test-all ──────────────────────────────────────────────── + alerts + .command("test-all") + .description("Send test alerts to every configured channel for a contract") + .requiredOption("--contract ", "The contract ID to test alerts for") + .action(async (options) => { + const contractId = options.contract; + const db = getDatabase(); + + const contract = getContract(db, contractId); + if (!contract) { + console.error(chalk.red(`Error: Contract ${formatContractID(contractId)} is not registered.`)); + process.exit(1); + } + + const configs = getAlertConfigsForContract(db, contractId); + if (configs.length === 0) { + console.log(chalk.yellow(`No alert configurations found for contract ${formatContractID(contractId)}.`)); + return; + } + + const results: Array<{ channelType: string; target: string; success: boolean; }> = []; + + for (const config of configs) { + const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers); + console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`); + + const success = await deliverSingleAlert( + config.channel_type, + config.channel_target, + testEvent, + config.webhook_secret, + ); + + results.push({ + channelType: config.channel_type, + target: config.channel_target, + success, + }); + } + + console.log(); + console.log(chalk.bold(` Alert Connectivity Summary for ${contract.name ?? formatContractID(contractId)}`)); + console.log(); + for (const result of results) { + console.log( + ` Type: ${chalk.cyan(result.channelType.padEnd(10))} | ` + + `Target: ${chalk.yellow(result.target.padEnd(30))} | ` + + `Status: ${result.success ? chalk.green("success") : chalk.red("failure")}` + ); + } + console.log(); + + const failedCount = results.filter((result) => !result.success).length; + if (failedCount > 0) { + console.error(chalk.red(`${failedCount} channel(s) failed connectivity testing.`)); + process.exit(1); + } + }); + // ── alerts history ───────────────────────────────────────────────── alerts .command("history") @@ -294,4 +383,4 @@ export function registerAlertsCommand(program: Command): void { } console.log(); }); -} \ No newline at end of file +} diff --git a/tests/commands/alerts.test.ts b/tests/commands/alerts.test.ts index 4e5f175d..8797bd89 100644 --- a/tests/commands/alerts.test.ts +++ b/tests/commands/alerts.test.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import type Database from "better-sqlite3"; import { getDatabaseForTesting } from "../../src/db/database"; import { registerAlertsCommand } from "../../src/commands/alerts"; +import { registerAlertChannel } from "../../src/alerts/registry"; import { insertContract, getAlertConfigsForContract, @@ -761,4 +762,95 @@ describe("alerts command", () => { ); }); }); + + describe("alerts test-all", () => { + beforeEach(() => { + mockDeliverSingleAlert.mockReset(); + insertAlertConfig(mockDb, { + contract_id: contractID, + channel_type: "webhook", + channel_target: "https://example.com/webhook", + threshold_ledgers: 1000, + }); + insertAlertConfig(mockDb, { + contract_id: contractID, + channel_type: "slack", + channel_target: "#ops-alerts", + threshold_ledgers: 2000, + }); + }); + + it("sends a test event to every configured channel for the contract", async () => { + mockDeliverSingleAlert.mockResolvedValue(true); + + const program = new Command(); + registerAlertsCommand(program); + + await program.parseAsync([ + "node", "sorokeep", "alerts", "test-all", + "--contract", contractID, + ]); + + expect(mockDeliverSingleAlert).toHaveBeenCalledTimes(2); + expect(mockDeliverSingleAlert.mock.calls[0]![0]).toBe("webhook"); + expect(mockDeliverSingleAlert.mock.calls[0]![1]).toBe("https://example.com/webhook"); + expect(mockDeliverSingleAlert.mock.calls[1]![0]).toBe("slack"); + expect(mockDeliverSingleAlert.mock.calls[1]![1]).toBe("#ops-alerts"); + + for (const [, , event] of mockDeliverSingleAlert.mock.calls) { + expect(event.type).toBe("threshold_crossed"); + expect(event.contractId).toBe(contractID); + } + }); + + it("exits with 1 when any delivery fails", async () => { + mockDeliverSingleAlert.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const program = new Command(); + registerAlertsCommand(program); + + await expect( + program.parseAsync([ + "node", "sorokeep", "alerts", "test-all", + "--contract", contractID, + ]) + ).rejects.toThrow("process.exit called"); + + expect(mockDeliverSingleAlert).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("1 channel(s) failed") + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("alerts channels", () => { + it("prints all five built-in channel names", () => { + parse(["alerts", "channels"]); + + const output = consoleLogSpy.mock.calls.flat().join("\n"); + expect(output).toContain("webhook"); + expect(output).toContain("slack"); + expect(output).toContain("pagerduty"); + expect(output).toContain("discord"); + expect(output).toContain("telegram"); + }); + + it("includes dynamically registered plugin channels in the output", () => { + registerAlertChannel({ + name: "matrix-listing-test", + channel: { send: vi.fn().mockResolvedValue(undefined) }, + targetOption: "url", + missingTargetError: "Error: --url is required when --type is matrix-listing-test.", + supportsSigning: false, + }); + + parse(["alerts", "channels"]); + + const output = consoleLogSpy.mock.calls.flat().join("\n"); + expect(output).toContain("matrix-listing-test"); + expect(output).toContain("url"); + expect(output).toContain("no"); + }); + }); });