From f57d1abcbd0e7e8dc2e9fc06569fadf2f9347ac4 Mon Sep 17 00:00:00 2001 From: habdullhigh Date: Sun, 5 Jul 2026 08:41:02 +0100 Subject: [PATCH] feat: config fallback, slack webhook, unwatch, pause/resume monitoring --- package-lock.json | 1 - src/alerts/dispatcher.ts | 6 +- src/alerts/slack.test.ts | 284 ----------------------- src/alerts/slack.ts | 223 +++++------------- src/commands/daemon.ts | 18 +- src/commands/pause.ts | 34 +++ src/commands/resume.ts | 33 +++ src/commands/watch.ts | 49 ++++ src/core/monitor.ts | 4 +- src/db/database.ts | 1 + src/db/repositories.ts | 19 +- src/db/schema.sql | 1 + src/index.ts | 4 + tests/alerts/slack.test.ts | 115 +++++++++ tests/alerts/state_change_alerts.test.ts | 17 +- tests/commands/daemon.test.ts | 27 ++- tests/commands/pause.test.ts | 67 ++++++ tests/commands/watch.test.ts | 88 ++++++- 18 files changed, 513 insertions(+), 478 deletions(-) delete mode 100644 src/alerts/slack.test.ts create mode 100644 src/commands/pause.ts create mode 100644 src/commands/resume.ts create mode 100644 tests/alerts/slack.test.ts create mode 100644 tests/commands/pause.test.ts diff --git a/package-lock.json b/package-lock.json index 1fae8247..7e598251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6130,7 +6130,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/alerts/dispatcher.ts b/src/alerts/dispatcher.ts index 5d3e8ee7..a70eb255 100644 --- a/src/alerts/dispatcher.ts +++ b/src/alerts/dispatcher.ts @@ -8,7 +8,7 @@ import { type UndeliveredAlert, } from "../db/repositories.js"; import { sendWebhookAlert } from "./webhook.js"; -import { sendSlackAlert } from "./slack.js"; +import { SlackChannel } from "./slack.js"; import { sendPagerDutyAlert } from "./pagerduty.js"; import { getLogger } from "../logging/index.js"; @@ -43,7 +43,7 @@ async function dispatchToChannel(alert: UndeliveredAlert, event: AlertEvent): Pr await sendWebhookAlert(alert.channelTarget, event, alert.webhookSecret); break; case "slack": - await sendSlackAlert(alert.channelTarget, event); + await new SlackChannel(alert.channelTarget).send(event); break; case "pagerduty": await sendPagerDutyAlert(alert.channelTarget, event); @@ -102,7 +102,7 @@ export async function deliverSingleAlert( await sendWebhookAlert(channelTarget, event, secret); break; case "slack": - await sendSlackAlert(channelTarget, event); + await new SlackChannel(channelTarget).send(event); break; case "pagerduty": await sendPagerDutyAlert(channelTarget, event); diff --git a/src/alerts/slack.test.ts b/src/alerts/slack.test.ts deleted file mode 100644 index 52915559..00000000 --- a/src/alerts/slack.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// ─── Mock fetch before importing the module under test ──────────────────────── - -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - -import { sendSlackAlert } from "../../src/alerts/slack"; -import type { AlertEvent } from "../../src/alerts/types"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -const VALID_TOKEN = "xoxb-test-slack-bot-token"; - -function makeAlertEvent(overrides: Partial = {}): AlertEvent { - return { - type: "threshold_crossed", - severity: "warning", - contractId: "CDEF1234ABCD5678", - contractName: "my-defi-pool", - network: "mainnet", - entry: { - keyXdr: "AAAA1234", - type: "instance", - label: "Contract Instance", - }, - threshold: { - configuredLedgers: 10_000, - currentRemainingLedgers: 4_200, - approximateTimeRemaining: "~6h 25m", - }, - firedAtLedger: 2_500_000, - timestamp: "2026-05-21T20:37:08.000Z", - ...overrides, - }; -} - -function makeSlackOkResponse(): Response { - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function makeSlackErrorResponse(error: string): Response { - return new Response(JSON.stringify({ ok: false, error }), { - status: 200, // Slack always returns 200, error is in body - headers: { "content-type": "application/json" }, - }); -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe("sendSlackAlert", () => { - beforeEach(() => { - vi.clearAllMocks(); - process.env["SOROKEEP_SLACK_TOKEN"] = VALID_TOKEN; - }); - - afterEach(() => { - delete process.env["SOROKEEP_SLACK_TOKEN"]; - vi.unstubAllGlobals(); - vi.stubGlobal("fetch", mockFetch); - }); - - // ========================================================================= - // 1. TOKEN VALIDATION - // ========================================================================= - describe("Token validation", () => { - it("throws a clear error when no Slack token is configured", async () => { - delete process.env["SOROKEEP_SLACK_TOKEN"]; - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toThrow(/[Ss]lack token/); - }); - - it("throws when SOROKEEP_SLACK_TOKEN is an empty string and no config token", async () => { - process.env["SOROKEEP_SLACK_TOKEN"] = ""; - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toThrow(/[Ss]lack token/); - }); - - it("uses the token in the Authorization header", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - expect(options.headers["Authorization"]).toBe(`Bearer ${VALID_TOKEN}`); - }); - }); - - // ========================================================================= - // 2. HTTP REQUEST SHAPE - // ========================================================================= - describe("HTTP request shape", () => { - it("calls the Slack chat.postMessage endpoint", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [url] = mockFetch.mock.calls[0]!; - expect(url).toContain("chat.postMessage"); - }); - - it("uses HTTP POST", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - expect(options.method).toBe("POST"); - }); - - it("sets Content-Type to application/json", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - expect(options.headers["Content-Type"]).toBe("application/json"); - }); - - it("sends the correct channel in the body", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#my-alerts", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - expect(body.channel).toBe("#my-alerts"); - }); - }); - - // ========================================================================= - // 3. MESSAGE CONTENT - // ========================================================================= - describe("Message content", () => { - it("includes the contract name in the message text", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ contractName: "defi-pool-v2" }); - - await sendSlackAlert("#oncall", event); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - const text = body.text ?? JSON.stringify(body.blocks); - expect(text).toContain("defi-pool-v2"); - }); - - it("includes the remaining TTL in the message", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ - threshold: { - configuredLedgers: 10_000, - currentRemainingLedgers: 4_200, - approximateTimeRemaining: "~6h 25m", - }, - }); - - await sendSlackAlert("#oncall", event); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - const text = body.text ?? JSON.stringify(body.blocks); - expect(text).toMatch(/4.?200|4200/); // either 4,200 or 4200 - }); - - it("includes the network name in the message", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ network: "mainnet" }); - - await sendSlackAlert("#oncall", event); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - const text = body.text ?? JSON.stringify(body.blocks); - expect(text).toContain("mainnet"); - }); - - it("uses a warning emoji / indicator for threshold_crossed events", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ type: "threshold_crossed" }); - - await sendSlackAlert("#oncall", event); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - const text = body.text ?? JSON.stringify(body.blocks); - // Should contain a warning indicator — emoji or text - expect(text.toLowerCase()).toMatch(/warning|⚠|alert|critical|ttl/i); - }); - - it("uses a resolved indicator for alert_resolved events", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ type: "alert_resolved" }); - - await sendSlackAlert("#oncall", event); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - const text = body.text ?? JSON.stringify(body.blocks); - expect(text.toLowerCase()).toMatch(/resolved|recovered|✅|ok/i); - }); - - it("falls back gracefully when contractName is null", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - const event = makeAlertEvent({ contractName: null }); - - await expect( - sendSlackAlert("#oncall", event), - ).resolves.not.toThrow(); - }); - }); - - // ========================================================================= - // 4. ERROR HANDLING - // ========================================================================= - describe("Error handling", () => { - it("throws when Slack API returns ok: false", async () => { - mockFetch.mockResolvedValue(makeSlackErrorResponse("channel_not_found")); - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toThrow("channel_not_found"); - }); - - it("throws when Slack API returns ok: false with invalid_auth", async () => { - mockFetch.mockResolvedValue(makeSlackErrorResponse("invalid_auth")); - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toThrow("invalid_auth"); - }); - - it("throws when fetch itself rejects (network error)", async () => { - mockFetch.mockRejectedValue(new Error("ECONNREFUSED")); - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toThrow("ECONNREFUSED"); - }); - - it("throws on HTTP 500 from Slack", async () => { - mockFetch.mockResolvedValue( - new Response("Internal Server Error", { status: 500 }), - ); - - await expect( - sendSlackAlert("#oncall", makeAlertEvent()), - ).rejects.toBeDefined(); - }); - }); - - // ========================================================================= - // 5. BODY STRUCTURE - // ========================================================================= - describe("Body structure", () => { - it("includes a blocks array in the body", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - expect(Array.isArray(body.blocks)).toBe(true); - expect(body.blocks.length).toBeGreaterThan(0); - }); - - it("includes a fallback text field", async () => { - mockFetch.mockResolvedValue(makeSlackOkResponse()); - - await sendSlackAlert("#oncall", makeAlertEvent()); - - const [, options] = mockFetch.mock.calls[0]!; - const body = JSON.parse(options.body as string); - expect(typeof body.text).toBe("string"); - expect(body.text.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/alerts/slack.ts b/src/alerts/slack.ts index 672c0f2d..444f6eee 100644 --- a/src/alerts/slack.ts +++ b/src/alerts/slack.ts @@ -1,37 +1,9 @@ import type { AlertEvent } from "./types.js"; -import { loadConfig } from "../utils/config.js"; import { getLogger } from "../logging/index.js"; const logger = getLogger().child({ component: "SlackHandler" }); -const SLACK_API_URL = "https://slack.com/api/chat.postMessage"; const TIMEOUT_MS = 10_000; -// ─── Token resolution ───────────────────────────────────────────────────────── - -/** - * Resolve the Slack Bot Token from config or environment. - * Priority: SOROKEEP_SLACK_TOKEN env var > config.slackToken - */ -function resolveSlackToken(): string { - const envToken = process.env["SOROKEEP_SLACK_TOKEN"]; - if (envToken) return envToken; - - const config = loadConfig(); - if (config.slackToken) return config.slackToken; - - throw new Error( - "Slack token not configured. Set SOROKEEP_SLACK_TOKEN environment variable " + - "or add slackToken to ~/.sorokeep/config.yaml.", - ); -} - -// ─── Block Kit builder ──────────────────────────────────────────────────────── - -interface SlackBlock { - type: string; - [key: string]: unknown; -} - function severityEmoji(event: AlertEvent): string { if (event.type === "alert_resolved") return "✅"; if (event.type === "state_changed") return "🔄"; @@ -39,7 +11,7 @@ function severityEmoji(event: AlertEvent): string { return "⚠️"; } -function buildBlocks(event: AlertEvent): SlackBlock[] { +function buildBlocks(event: AlertEvent): any[] { const icon = severityEmoji(event); const contractDisplay = event.contractName ?? event.contractId; @@ -56,7 +28,7 @@ function buildBlocks(event: AlertEvent): SlackBlock[] { status = "Alert Resolved"; } - const header: SlackBlock = { + const header = { type: "header", text: { type: "plain_text", @@ -65,180 +37,95 @@ function buildBlocks(event: AlertEvent): SlackBlock[] { }, }; - let details: SlackBlock; + let details: any; if (event.type === "resource_alert") { const usageStr = event.resource.currentUsage.toLocaleString(); const limitStr = event.resource.limit.toLocaleString(); - details = { type: "section", fields: [ - { - type: "mrkdwn", - text: `*Resource:*\n${event.resource.type.toUpperCase()}`, - }, - { - type: "mrkdwn", - text: `*Network:*\n${event.network}`, - }, - { - type: "mrkdwn", - text: `*Usage:*\n${usageStr} / ${limitStr} (${event.resource.usagePercent}%)`, - }, - { - type: "mrkdwn", - text: `*Severity:*\n${event.severity}`, - }, + { type: "mrkdwn", text: `*Resource:*\n${event.resource.type.toUpperCase()}` }, + { type: "mrkdwn", text: `*Network:*\n${event.network}` }, + { type: "mrkdwn", text: `*Usage:*\n${usageStr} / ${limitStr} (${event.resource.usagePercent}%)` }, + { type: "mrkdwn", text: `*Severity:*\n${event.severity}` }, ], }; } else if (event.type === "state_changed") { const entryLabel = event.entry.label ?? event.entry.type; const oldVal = event.diff.oldValueXdr ?? "(none)"; const newVal = event.diff.newValueXdr ?? "(none)"; - details = { type: "section", fields: [ - { - type: "mrkdwn", - text: `*Entry:*\n${entryLabel}`, - }, - { - type: "mrkdwn", - text: `*Network:*\n${event.network}`, - }, - { - type: "mrkdwn", - text: `*Change Type:*\n${event.diff.diffType}`, - }, - { - type: "mrkdwn", - text: `*Old Value:*\n\`${oldVal}\``, - }, - { - type: "mrkdwn", - text: `*New Value:*\n\`${newVal}\``, - }, + { type: "mrkdwn", text: `*Entry:*\n${entryLabel}` }, + { type: "mrkdwn", text: `*Network:*\n${event.network}` }, + { type: "mrkdwn", text: `*Change Type:*\n${event.diff.diffType}` }, + { type: "mrkdwn", text: `*Old Value:*\n\`${oldVal}\`` }, + { type: "mrkdwn", text: `*New Value:*\n\`${newVal}\`` }, ], }; } else { details = { type: "section", fields: [ - { - type: "mrkdwn", - text: `*Entry:*\n${event.entry.label ?? event.entry.type}`, - }, - { - type: "mrkdwn", - text: `*Network:*\n${event.network}`, - }, - { - type: "mrkdwn", - text: `*Remaining TTL:*\n${event.threshold.currentRemainingLedgers.toLocaleString()} ledgers (${event.threshold.approximateTimeRemaining})`, - }, - { - type: "mrkdwn", - text: `*Threshold:*\n${event.threshold.configuredLedgers.toLocaleString()} ledgers`, - }, + { type: "mrkdwn", text: `*Entry:*\n${event.entry.label ?? event.entry.type}` }, + { type: "mrkdwn", text: `*Network:*\n${event.network}` }, + { type: "mrkdwn", text: `*Remaining TTL:*\n${event.threshold.currentRemainingLedgers.toLocaleString()} ledgers (${event.threshold.approximateTimeRemaining})` }, + { type: "mrkdwn", text: `*Threshold:*\n${event.threshold.configuredLedgers.toLocaleString()} ledgers` }, ], }; } - const footer: SlackBlock = { + const footer = { type: "context", elements: [ - { - type: "mrkdwn", - text: `Severity: *${event.severity}* | Run \`sorokeep status ${event.contractId}\` for details.`, - }, + { type: "mrkdwn", text: `Severity: *${event.severity}* | Run \`sorokeep status ${event.contractId}\` for details.` }, ], }; return [header, details, footer]; } -function buildFallbackText(event: AlertEvent): string { - const icon = severityEmoji(event); - const contractDisplay = event.contractName ?? event.contractId; - - if (event.type === "resource_alert") { - const resourceType = event.resource.type === "cpu" ? "CPU" : "Memory"; - const status = `Resource ${resourceType} ${event.severity === "critical" ? "CRITICAL" : "Warning"}`; - return ( - `${icon} ${status} — ${contractDisplay} (${event.network}) | ` + - `Usage: ${event.resource.currentUsage.toLocaleString()} / ${event.resource.limit.toLocaleString()} ` + - `(${event.resource.usagePercent}%)` - ); - } else if (event.type === "state_changed") { - const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1); - return ( - `${icon} State ${diffLabel} — ${contractDisplay} (${event.network}) | ` + - `Entry: ${event.entry.label ?? event.entry.type} | ` + - `Old: ${event.diff.oldValueXdr ?? "(none)"} → New: ${event.diff.newValueXdr ?? "(none)"}` - ); - } else if (event.type === "threshold_crossed") { - const status = `TTL ${event.severity === "critical" ? "CRITICAL" : "Warning"}`; - return ( - `${icon} ${status} — ${contractDisplay} (${event.network}) | ` + - `Remaining: ${event.threshold.currentRemainingLedgers.toLocaleString()} ledgers ` + - `(${event.threshold.approximateTimeRemaining}) | ` + - `Threshold: ${event.threshold.configuredLedgers.toLocaleString()} ledgers` - ); - } else { - return `${icon} Alert Resolved — ${contractDisplay} (${event.network})`; +export class SlackChannel { + constructor(private readonly webhookUrl: string) { + if (!webhookUrl || !webhookUrl.startsWith("http")) { + throw new Error("Invalid Slack webhook URL"); + } } -} - -// ─── Public API ─────────────────────────────────────────────────────────────── -/** - * Send an AlertEvent to a Slack channel via the Slack Web API. - * - * Resolves the Bot Token from env (SOROKEEP_SLACK_TOKEN) or config (slackToken). - * Throws when the token is absent, the network fails, or Slack returns ok: false. - * The caller (dispatcher) handles retry via the `delivered` flag. - */ -export async function sendSlackAlert(channel: string, event: AlertEvent): Promise { - const token = resolveSlackToken(); + async send(event: AlertEvent): Promise { + logger.debug(`Sending Slack alert to webhook`, { type: event.type, contractId: event.contractId }); - logger.debug(`Sending Slack alert to ${channel}`, { type: event.type, contractId: event.contractId }); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - - let response: Response; - try { - response = await fetch(SLACK_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - }, - body: JSON.stringify({ - channel, - text: buildFallbackText(event), - blocks: buildBlocks(event), - }), - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error( - `Slack API request failed: HTTP ${response.status}`, - ); - } + const payload = { + blocks: buildBlocks(event), + }; - // Slack always returns HTTP 200, but errors are in the body as ok: false - const body = await response.json() as { ok: boolean; error?: string }; - if (!body.ok) { - throw new Error( - `Slack API error: ${body.error ?? "unknown error"}`, - ); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(this.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + let errorMsg = `HTTP ${response.status}`; + try { + const text = await response.text(); + if (text) errorMsg += `: ${text}`; + } catch { + // ignore + } + throw new Error(errorMsg); + } + + logger.debug(`Slack alert delivered successfully`); } - - logger.debug(`Slack alert delivered successfully to ${channel}`); } diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 28da1985..cfeb789c 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -9,17 +9,22 @@ export function registerDaemonCommand(program: Command): void { program .command("daemon") .description("Start the monitoring daemon — polls contracts at a fixed interval") - .option("--network ", "Stellar network to monitor", "testnet") - .option("--interval ", "Polling interval in milliseconds", "300000") + .option("--network ", "Stellar network to monitor") + .option("--interval ", "Polling interval in milliseconds") .option("-r, --rpc-url ", "Custom RPC endpoint URL") .option("--log-format ", "Log output format: 'pretty' (human-readable) or 'json' (structured)", "pretty") .action(async (options: { - network: string; - interval: string; + network?: string; + interval?: string; rpcUrl?: string; logFormat: string; }) => { - const intervalMs = parseInt(options.interval, 10); + const config = loadConfig(); + const network = options.network ?? config.network; + const intervalMs = options.interval !== undefined + ? parseInt(options.interval, 10) + : config.pollingIntervalSeconds * 1000; + if (isNaN(intervalMs) || intervalMs < 10000) { console.log(chalk.red("Error: --interval must be a number >= 10000 (10 seconds)")); process.exit(1); @@ -70,8 +75,7 @@ export function registerDaemonCommand(program: Command): void { process.on("SIGTERM", shutdown); // ── Start the loop ─────────────────────────────────────── - const config = loadConfig(); - await startDaemon(db, options.network, { + await startDaemon(db, network, { intervalMs, rpcUrl: options.rpcUrl, feeSponsorSecret: config.feeSponsorSecret, diff --git a/src/commands/pause.ts b/src/commands/pause.ts new file mode 100644 index 00000000..e5cfb827 --- /dev/null +++ b/src/commands/pause.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getDatabase } from "../db/database.js"; +import { getContract, setContractActiveStatus } from "../db/repositories.js"; +import { formatContractID } from "../utils/formatting.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "PauseCommand" }); + +export const registerPauseCommand = (program: Command): void => { + program + .command("pause ") + .description("Temporarily pause daemon polling and alerting for a contract") + .action(async (contractId: string) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`)); + process.exit(1); + } + + setContractActiveStatus(db, contractId, false); + console.log(chalk.green(`Successfully paused monitoring for ${formatContractID(contractId)}.`)); + console.log(chalk.dim(`Run 'sorokeep resume ${formatContractID(contractId)}' to re-enable.`)); + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Pause command failed", { error: errorMessage }); + console.log(chalk.red(`Failed to pause contract: ${errorMessage}`)); + process.exit(1); + } + }); +}; diff --git a/src/commands/resume.ts b/src/commands/resume.ts new file mode 100644 index 00000000..1c5b9239 --- /dev/null +++ b/src/commands/resume.ts @@ -0,0 +1,33 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getDatabase } from "../db/database.js"; +import { getContract, setContractActiveStatus } from "../db/repositories.js"; +import { formatContractID } from "../utils/formatting.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "ResumeCommand" }); + +export const registerResumeCommand = (program: Command): void => { + program + .command("resume ") + .description("Resume daemon polling and alerting for a paused contract") + .action(async (contractId: string) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`)); + process.exit(1); + } + + setContractActiveStatus(db, contractId, true); + console.log(chalk.green(`Successfully resumed monitoring for ${formatContractID(contractId)}.`)); + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Resume command failed", { error: errorMessage }); + console.log(chalk.red(`Failed to resume contract: ${errorMessage}`)); + process.exit(1); + } + }); +}; diff --git a/src/commands/watch.ts b/src/commands/watch.ts index d3b1b3f5..49c13486 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,7 +1,9 @@ import { Command } from "commander"; import chalk from "chalk"; +import readline from "node:readline"; import ora from "ora"; import { getDatabase } from "../db/database.js"; +import { deleteContract, getContract } from "../db/repositories.js"; import { getLogger } from "../logging/index.js"; import { classifyTTL, @@ -163,6 +165,53 @@ export const registerWatchCommand = (program: Command): void => { process.exit(1); } }); + + program + .command("unwatch ") + .description("Remove a registered contract and clean up associated logs") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (contractId: string, options: { yes?: boolean }) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`)); + process.exit(1); + } + + const proceedWithDeletion = async (): Promise => { + deleteContract(db, contractId); + console.log(chalk.green(`Successfully unwatched contract ${formatContractID(contractId)}.`)); + }; + + if (options.yes) { + await proceedWithDeletion(); + } else { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question( + chalk.yellow(`Are you sure you want to unwatch ${formatContractID(contractId)}? All associated logs, alerts, and policies will be permanently deleted. (y/N): `), + async (answer) => { + rl.close(); + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + await proceedWithDeletion(); + } else { + console.log("Unwatch cancelled."); + } + } + ); + } + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Unwatch command failed", { error: errorMessage }); + console.log(chalk.red(`Failed to unwatch contract: ${errorMessage}`)); + process.exit(1); + } + }); }; function printBatchSummary( diff --git a/src/core/monitor.ts b/src/core/monitor.ts index a89b28a6..0ab2a9f5 100644 --- a/src/core/monitor.ts +++ b/src/core/monitor.ts @@ -85,8 +85,8 @@ export async function runMonitorCycle( // One RPC client shared across the cycle for the target network. const client = new StellarRpcClient(network, rpcUrl); - // 1. Load all contracts and filter to the target network. - const contracts = getAllContracts(db).filter(c => c.network === network); + // 1. Load all contracts and filter to the target network and active status. + const contracts = getAllContracts(db).filter(c => c.network === network && c.active === 1); logger.debug(`Monitor cycle started — ${contracts.length} contract(s) on ${network}`); diff --git a/src/db/database.ts b/src/db/database.ts index 4558eb61..170b086d 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -63,6 +63,7 @@ export function getDatabase(customPath?: string): Database.Database { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `ALTER TABLE contracts ADD COLUMN last_introspected_at DATETIME`, + `ALTER TABLE contracts ADD COLUMN active INTEGER NOT NULL DEFAULT 1`, ]; for (const sql of migrations) { try { db.exec(sql); } catch { /* column already exists — no-op */ } diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 5169fb6d..2fb11cc3 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -7,6 +7,7 @@ export interface Contract { wasm_hash: string | null; tags: string | null; poll_interval_seconds?: number | null; + active: number; registered_at: Date; last_checked_ledger?: number | null; /** ISO-8601 timestamp of the last successful introspection (instance/WASM key discovery). NULL if never introspected. */ @@ -94,16 +95,17 @@ export interface StateChange { } // ---------------------------- Database Access Functions For Schema: Contract ---------------------------- -export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null;}): void { +export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null; active?: number}): void { db.prepare(` - INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds) - VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds) + INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds, active) + VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds, @active) ON CONFLICT(id) DO UPDATE SET name = excluded.name, network = excluded.network, wasm_hash = excluded.wasm_hash, tags = excluded.tags, - poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds) + poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds), + active = COALESCE(excluded.active, contracts.active) `).run({ id: contract.id, name: contract.name ?? null, @@ -111,6 +113,7 @@ export function insertContract(db: Database.Database, contract: {id: string; nam wasm_hash: contract.wasm_hash ?? null, tags: contract.tags ?? null, poll_interval_seconds: contract.poll_interval_seconds ?? null, + active: contract.active ?? 1, }); } @@ -146,6 +149,14 @@ export function getContractPollInterval( return row?.poll_interval_seconds ?? null; } +export function setContractActiveStatus( + db: Database.Database, + contractId: string, + active: boolean +): void { + db.prepare("UPDATE contracts SET active = ? WHERE id = ?").run(active ? 1 : 0, contractId); +} + /** * Record that a successful contract introspection (instance/WASM key discovery) * was performed at the given timestamp. Accepts an ISO-8601 string so callers diff --git a/src/db/schema.sql b/src/db/schema.sql index b26325c0..ea06e9c5 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS contracts ( wasm_hash TEXT, tags TEXT, poll_interval_seconds INTEGER, + active INTEGER NOT NULL DEFAULT 1, registered_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_checked_ledger INTEGER, last_introspected_at DATETIME diff --git a/src/index.ts b/src/index.ts index 6d58224e..3e2eeb67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ import { registerCompletionCommand } from "./commands/completion.js"; import { registerInspectCommand } from "./commands/inspect.js"; import { registerBudgetCommand } from "./commands/budget.js"; import { registerDbCommand } from "./commands/db.js"; +import { registerPauseCommand } from "./commands/pause.js"; +import { registerResumeCommand } from "./commands/resume.js"; initLogger({ mode: "cli" }); @@ -43,5 +45,7 @@ registerCompletionCommand(program); registerInspectCommand(program); registerBudgetCommand(program); registerDbCommand(program); +registerPauseCommand(program); +registerResumeCommand(program); program.parse(process.argv); diff --git a/tests/alerts/slack.test.ts b/tests/alerts/slack.test.ts new file mode 100644 index 00000000..920d5d90 --- /dev/null +++ b/tests/alerts/slack.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { SlackChannel } from "../../src/alerts/slack"; +import type { AlertEvent } from "../../src/alerts/types"; + +const VALID_WEBHOOK = "https://example.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"; + +function makeAlertEvent(overrides: Partial = {}): AlertEvent { + return { + type: "threshold_crossed", + severity: "warning", + contractId: "CDEF1234ABCD5678", + contractName: "my-defi-pool", + network: "mainnet", + entry: { + keyXdr: "AAAA1234", + type: "instance", + label: "Contract Instance", + }, + threshold: { + configuredLedgers: 10_000, + currentRemainingLedgers: 4_200, + approximateTimeRemaining: "~6h 25m", + }, + firedAtLedger: 2_500_000, + timestamp: "2026-05-21T20:37:08.000Z", + ...overrides, + }; +} + +function makeSlackOkResponse(): Response { + return new Response("ok", { + status: 200, + headers: { "content-type": "text/html" }, + }); +} + +function makeSlackErrorResponse(error: string): Response { + return new Response(error, { + status: 400, + headers: { "content-type": "text/html" }, + }); +} + +describe("SlackChannel", () => { + let channel: SlackChannel; + + beforeEach(() => { + vi.clearAllMocks(); + channel = new SlackChannel(VALID_WEBHOOK); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal("fetch", mockFetch); + }); + + describe("Webhook URL validation", () => { + it("throws a clear error when URL is missing", async () => { + expect(() => new SlackChannel("")).toThrow(/webhook URL/); + }); + it("throws when URL is invalid", async () => { + expect(() => new SlackChannel("not-a-url")).toThrow(/webhook URL/); + }); + }); + + describe("HTTP request shape", () => { + it("calls the provided webhook URL", async () => { + mockFetch.mockResolvedValue(makeSlackOkResponse()); + await channel.send(makeAlertEvent()); + const [url] = mockFetch.mock.calls[0]!; + expect(url).toBe(VALID_WEBHOOK); + }); + + it("uses HTTP POST", async () => { + mockFetch.mockResolvedValue(makeSlackOkResponse()); + await channel.send(makeAlertEvent()); + const [, options] = mockFetch.mock.calls[0]!; + expect(options.method).toBe("POST"); + }); + + it("sets Content-Type to application/json", async () => { + mockFetch.mockResolvedValue(makeSlackOkResponse()); + await channel.send(makeAlertEvent()); + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["Content-Type"]).toBe("application/json"); + }); + }); + + describe("Message content", () => { + it("includes the contract name in the message blocks", async () => { + mockFetch.mockResolvedValue(makeSlackOkResponse()); + const event = makeAlertEvent({ contractName: "defi-pool-v2" }); + await channel.send(event); + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + expect(JSON.stringify(body.blocks)).toContain("defi-pool-v2"); + }); + }); + + describe("Error handling", () => { + it("throws when API returns non-2xx", async () => { + mockFetch.mockResolvedValue(makeSlackErrorResponse("invalid_payload")); + await expect(channel.send(makeAlertEvent())).rejects.toThrow("invalid_payload"); + }); + + it("throws when fetch itself rejects (network error)", async () => { + mockFetch.mockRejectedValue(new Error("ECONNREFUSED")); + await expect(channel.send(makeAlertEvent())).rejects.toThrow("ECONNREFUSED"); + }); + }); +}); diff --git a/tests/alerts/state_change_alerts.test.ts b/tests/alerts/state_change_alerts.test.ts index 8979cbd8..7dce07f7 100644 --- a/tests/alerts/state_change_alerts.test.ts +++ b/tests/alerts/state_change_alerts.test.ts @@ -19,9 +19,14 @@ import { deliverSingleAlert } from "../../src/alerts/dispatcher"; vi.mock("../../src/alerts/webhook.js", () => ({ sendWebhookAlert: vi.fn().mockResolvedValue(undefined), })); -vi.mock("../../src/alerts/slack.js", () => ({ - sendSlackAlert: vi.fn().mockResolvedValue(undefined), -})); +const mockSlackSend = vi.fn().mockResolvedValue(undefined); +vi.mock("../../src/alerts/slack.js", () => { + return { + SlackChannel: vi.fn().mockImplementation(() => { + return { send: mockSlackSend }; + }) + }; +}); vi.mock("../../src/alerts/pagerduty.js", () => ({ sendPagerDutyAlert: vi.fn().mockResolvedValue(undefined), })); @@ -178,7 +183,7 @@ describe("State Change Alerts", () => { }); it("delivers state_changed event via slack", async () => { - const { sendSlackAlert } = await import("../../src/alerts/slack.js"); + const { SlackChannel } = await import("../../src/alerts/slack.js"); const event = buildStateChangeAlertEvent({ contractId: "CONTRACT_1", @@ -200,8 +205,8 @@ describe("State Change Alerts", () => { ); expect(success).toBe(true); - expect(sendSlackAlert).toHaveBeenCalledWith( - "#alerts", + expect(SlackChannel).toHaveBeenCalledWith("#alerts"); + expect(mockSlackSend).toHaveBeenCalledWith( expect.objectContaining({ type: "state_changed", diff: expect.objectContaining({ diff --git a/tests/commands/daemon.test.ts b/tests/commands/daemon.test.ts index 4fa17c21..93a4f573 100644 --- a/tests/commands/daemon.test.ts +++ b/tests/commands/daemon.test.ts @@ -3,9 +3,11 @@ import { registerDaemonCommand } from "../../src/commands/daemon"; import { Command } from "commander"; import * as dbLib from "../../src/db/database"; import * as daemonLib from "../../src/daemon/loop"; +import * as configLib from "../../src/utils/config"; vi.mock("../../src/db/database"); vi.mock("../../src/daemon/loop"); +vi.mock("../../src/utils/config"); function getDaemonCommand(): Command { const program = new Command(); @@ -52,6 +54,10 @@ describe("Daemon Command CLI", () => { mockLog = vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(dbLib, "getDatabase").mockReturnValue({ close: vi.fn() } as any); vi.spyOn(daemonLib, "startDaemon").mockImplementation(async () => {}); + vi.spyOn(configLib, "loadConfig").mockReturnValue({ + network: "testnet", + pollingIntervalSeconds: 300 + } as any); }); afterEach(() => { @@ -79,7 +85,7 @@ describe("Daemon Command CLI", () => { }); it("starts the daemon loop with correct options", async () => { - await actionFn({ network: "mainnet", interval: "300000", rpcUrl: "https://my-rpc.com" }); + await actionFn({ network: "mainnet", interval: "300000", rpcUrl: "https://my-rpc.com", logFormat: "pretty" }); expect(daemonLib.startDaemon).toHaveBeenCalledWith( expect.anything(), @@ -89,5 +95,22 @@ describe("Daemon Command CLI", () => { rpcUrl: "https://my-rpc.com" }) ); - }); + }); + + it("falls back to config values when interval and network are not provided", async () => { + vi.spyOn(configLib, "loadConfig").mockReturnValue({ + network: "futurenet", + pollingIntervalSeconds: 45 + } as any); + + await actionFn({ logFormat: "pretty" }); + + expect(daemonLib.startDaemon).toHaveBeenCalledWith( + expect.anything(), + "futurenet", + expect.objectContaining({ + intervalMs: 45000 + }) + ); + }); }); diff --git a/tests/commands/pause.test.ts b/tests/commands/pause.test.ts new file mode 100644 index 00000000..82672769 --- /dev/null +++ b/tests/commands/pause.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import * as dbLib from "../../src/db/database"; +import * as repositories from "../../src/db/repositories"; +import { registerPauseCommand } from "../../src/commands/pause"; +import { registerResumeCommand } from "../../src/commands/resume"; + +vi.mock("../../src/db/database"); +vi.mock("../../src/db/repositories"); + +describe("Pause and Resume Commands", () => { + let program: Command; + let mockExit: any; + let mockLog: any; + let pauseActionFn: (contractId: string) => Promise; + let resumeActionFn: (contractId: string) => Promise; + + beforeEach(() => { + program = new Command(); + + vi.spyOn(Command.prototype, "action").mockImplementation(function ( + this: any, + fn: any, + ) { + if (this.name() === "pause") pauseActionFn = fn; + if (this.name() === "resume") resumeActionFn = fn; + return this; + }); + + registerPauseCommand(program); + registerResumeCommand(program); + + mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + mockLog = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(dbLib, "getDatabase").mockReturnValue({ close: vi.fn() } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("pause exits with 1 if contract is not found", async () => { + vi.mocked(repositories.getContract).mockReturnValue(undefined); + await pauseActionFn("CDEF1234"); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("pause calls setContractActiveStatus with false", async () => { + vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); + await pauseActionFn("CDEF1234"); + expect(repositories.setContractActiveStatus).toHaveBeenCalledWith(expect.anything(), "CDEF1234", false); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Successfully paused")); + }); + + it("resume exits with 1 if contract is not found", async () => { + vi.mocked(repositories.getContract).mockReturnValue(undefined); + await resumeActionFn("CDEF1234"); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("resume calls setContractActiveStatus with true", async () => { + vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); + await resumeActionFn("CDEF1234"); + expect(repositories.setContractActiveStatus).toHaveBeenCalledWith(expect.anything(), "CDEF1234", true); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Successfully resumed")); + }); +}); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 7f2b1aca..5ad49a12 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -8,13 +8,17 @@ import * as watchConfig from "../../src/utils/watch-config"; vi.mock("../../src/db/database"); vi.mock("../../src/core/watch"); vi.mock("../../src/utils/watch-config"); +vi.mock("../../src/db/repositories"); +vi.mock("node:readline"); describe("Watch Command CLI", () => { let program: Command; let mockExit: any; let mockLog: any; let mockWarn: any; + let mockReadline: any; let actionFn: (contractId: string | undefined, options: any) => Promise; + let unwatchActionFn: (contractId: string, options: any) => Promise; beforeEach(() => { program = new Command(); @@ -23,7 +27,8 @@ describe("Watch Command CLI", () => { this: any, fn: any, ) { - actionFn = fn; + if (this.name() === "watch") actionFn = fn; + if (this.name() === "unwatch") unwatchActionFn = fn; return this; }); @@ -241,3 +246,84 @@ describe("Watch Command CLI", () => { expect(mockExit).toHaveBeenCalledWith(1); }); }); + +import * as repositories from "../../src/db/repositories"; +import readline from "node:readline"; + +describe("Unwatch Command CLI", () => { + let program: Command; + let mockExit: any; + let mockLog: any; + let unwatchActionFn: (contractId: string, options: any) => Promise; + let mockRl: any; + + beforeEach(() => { + program = new Command(); + + vi.spyOn(Command.prototype, "action").mockImplementation(function ( + this: any, + fn: any, + ) { + if (this.name() === "unwatch") unwatchActionFn = fn; + return this; + }); + + registerWatchCommand(program); + + mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + mockLog = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(dbLib, "getDatabase").mockReturnValue({ close: vi.fn() } as any); + + mockRl = { + question: vi.fn(), + close: vi.fn() + }; + vi.spyOn(readline, "createInterface").mockReturnValue(mockRl as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("exits with 1 when no contract id provided", async () => { + // commander requires it, but if bypassed: + await unwatchActionFn(undefined as any, {}); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("exits with 1 when contract is not found", async () => { + vi.mocked(repositories.getContract).mockReturnValue(undefined); + await unwatchActionFn("CDEF1234", {}); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("not being watched")); + }); + + it("deletes contract directly when --yes is passed", async () => { + vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); + await unwatchActionFn("CDEF1234", { yes: true }); + expect(repositories.deleteContract).toHaveBeenCalledWith(expect.anything(), "CDEF1234"); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Successfully unwatched")); + }); + + it("prompts for confirmation when --yes is not passed and deletes if confirmed", async () => { + vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); + mockRl.question.mockImplementation((query: string, cb: (ans: string) => void) => { + cb("yes"); + }); + await unwatchActionFn("CDEF1234", {}); + expect(mockRl.question).toHaveBeenCalled(); + expect(repositories.deleteContract).toHaveBeenCalledWith(expect.anything(), "CDEF1234"); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Successfully unwatched")); + }); + + it("does not delete if confirmation is denied", async () => { + vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); + mockRl.question.mockImplementation((query: string, cb: (ans: string) => void) => { + cb("no"); + }); + await unwatchActionFn("CDEF1234", {}); + expect(mockRl.question).toHaveBeenCalled(); + expect(repositories.deleteContract).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Unwatch cancelled")); + }); +});