diff --git a/README.md b/README.md index cd38d7ea..40a711a2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,18 @@ The daemon will check TTLs every 5 minutes, fire alerts when thresholds are cros ## Commands +### Global Options + +The following options can be used with any command: + +| Option | Description | +|--------|-------------| +| `-y, --yes` | Skip all confirmation prompts on destructive commands (e.g., `unwatch`). Use in scripts and CI pipelines to run non-interactively. | +| `-h, --help` | Show help for any command or subcommand. | +| `-V, --version` | Print the Sorokeep version. | + +> **Note:** `--yes` bypasses interactive confirmation prompts only. For the `check` command, use `--force` (see below) to bypass CI exit-code failures instead. + ### `sorokeep watch ` Register a contract for monitoring. Connects to the Stellar RPC, discovers the contract's instance and WASM code entries, reads their TTLs, and stores everything locally. @@ -185,7 +197,7 @@ The daemon handles graceful shutdown on `SIGINT`/`SIGTERM` and includes a re-ent ### `sorokeep alerts` -Manage alert configurations. Supports five subcommands. +Manage alert configurations. Supports subcommands for add, list, remove, enable, disable, test, test-all, history, stats, and channels. #### `alerts add` — Create a new alert @@ -196,7 +208,7 @@ sorokeep alerts add [options] | Option | Description | |--------|-------------| | `--contract ` | Contract ID to alert on (required) | -| `--type ` | `webhook` or `slack` (required) | +| `--type ` | Notification channel type — run `sorokeep alerts channels` for the full list (e.g. `webhook`, `slack`, `discord`, `telegram`, `pagerduty`, `email`) | | `--url ` | Webhook POST URL (required for webhook) | | `--channel ` | Slack channel name or ID (required for slack) | | `--threshold ` | Fire when remaining TTL drops below this (required) | @@ -352,6 +364,17 @@ Inspect on-chain state directly. Can parse Stellar Asset Contract (SAC) token ba Perform an ad-hoc, one-off execution of the monitoring cycle without starting the long-running daemon. +```bash +sorokeep check [options] +``` + +| Option | Description | +|--------|-------------| +| `--fail-under ` | Exit with code 1 if any entry TTL is below this many ledgers (required) | +| `--force` | Bypass CI TTL failures and exit 0 even when entries are below the threshold. Use in CI pipelines where you want to report TTL health without failing the build. | + +> **Note:** `--force` on `check` is different from the global `--yes` flag. `--force` overrides the exit code for CI/CD workflows; `--yes` skips interactive confirmation prompts on destructive commands. + --- ### `sorokeep db` @@ -685,7 +708,7 @@ Testnet (`https://soroban-testnet.stellar.org`) and Mainnet (`https://mainnet.so ### What about email alerts? -Email is not yet implemented. The CLI will reject `--type email` with a clear error message. Webhook and Slack are the supported channels today. +Email alerts are supported and delivered via SMTP. Configure the SMTP server (`host`/`port`/`user`/`pass`) in `~/.sorokeep/config.yaml`, then create an alert with `sorokeep alerts add --contract --type email --channel --threshold `. ## Roadmap diff --git a/README.pt.md b/README.pt.md index 406a4292..9daced67 100644 --- a/README.pt.md +++ b/README.pt.md @@ -101,6 +101,18 @@ O daemon verifica os TTls a cada 5 minutos, dispara alertas quando os limites s ## Comandos +### Opções Globais + +As seguintes opções podem ser usadas com qualquer comando: + +| Opção | Descrição | +|-------|-----------| +| `-y, --yes` | Ignora todos os prompts de confirmação em comandos destrutivos (ex.: `unwatch`). Use em scripts e pipelines de CI para executar de forma não interativa. | +| `-h, --help` | Mostra a ajuda de qualquer comando ou subcomando. | +| `-V, --version` | Imprime a versão do Sorokeep. | + +> **Nota:** `--yes` ignora apenas os prompts de confirmação interativos. Para o comando `check`, use `--force` (veja abaixo) para ignorar falhas de código de saída da CI. + ### `sorokeep watch ` Registre um contrato para monitoramento. Conecta ao Stellar RPC, descobre as entradas de instância e código WASM do contrato, lê seus TTls e armazena tudo localmente. diff --git a/man/sorokeep.1 b/man/sorokeep.1 index ffdcd3d2..b538fbbe 100644 --- a/man/sorokeep.1 +++ b/man/sorokeep.1 @@ -1,4 +1,4 @@ -.TH SOROKEEP 1 "2026-07-31" "sorokeep" "User Commands" +.TH SOROKEEP 1 "2026-08-04" "sorokeep" "User Commands" .SH NAME sorokeep - Sorokeep — The missing operations layer for deployed Soroban smart contracts .SH SYNOPSIS @@ -44,5 +44,9 @@ Backup, restore, and maintain database state Temporarily pause daemon polling and alerting for a contract .SH RESUME Resume daemon polling and alerting for a paused contract +.SH METRICS +Print a metrics snapshot without starting the HTTP server +.SH AUDIT-LOG +Export extension transactions as a machine-parseable JSONL audit trail .SH SEE ALSO sorokeep(1) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 96b389cd..94162a10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1217,7 +1217,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1985,7 +1984,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", @@ -2352,7 +2350,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3136,7 +3133,6 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -3410,7 +3406,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", @@ -3931,7 +3926,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4790,7 +4784,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5796,7 +5789,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5872,7 +5864,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5953,7 +5944,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -6052,7 +6042,6 @@ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", @@ -6179,7 +6168,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" }, @@ -6220,7 +6208,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/alerts/channels.test.ts b/src/alerts/channels.test.ts new file mode 100644 index 00000000..cbe0ded0 --- /dev/null +++ b/src/alerts/channels.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendSlackAlert } from "../../src/alerts/slack"; +import { sendPagerDutyAlert } from "../../src/alerts/pagerduty"; +import { sendDiscordAlert } from "../../src/alerts/discord"; +import { sendTelegramAlert } from "../../src/alerts/telegram"; +import { sendWebhookAlert } from "../../src/alerts/webhook"; +import type { AlertEvent } from "../../src/alerts/types"; +import { loadConfig } from "../../src/utils/config"; + +vi.mock("../../src/utils/config", () => ({ + loadConfig: vi.fn(), +})); + +const mockLoadConfig = vi.mocked(loadConfig); +const fetchSpy = vi.spyOn(global, "fetch"); + +describe("Alert Channels", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env = {}; // Clear env for fresh config resolution + + // Default successful fetch + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ ok: true }), + text: async () => "OK" + } as Response); + }); + + const mockEvent: AlertEvent = { + type: "threshold_crossed", + network: "testnet", + contractId: "C1234", + contractName: "TestContract", + severity: "critical", + message: "TTL critical", + entry: { type: "ContractData", label: "State" }, + threshold: { + currentRemainingLedgers: 10, + configuredLedgers: 20, + approximateTimeRemaining: "1m" + }, + }; + + const mockResourceEvent: AlertEvent = { + type: "resource_alert", + network: "testnet", + contractId: "C1234", + severity: "warning", + message: "CPU high", + resource: { + type: "cpu", + currentUsage: 90, + limit: 100, + usagePercent: 90 + } + }; + + describe("Slack", () => { + it("throws if no slack token provided", async () => { + mockLoadConfig.mockReturnValue({}); + await expect(sendSlackAlert("C123", mockEvent)).rejects.toThrow(/Slack token not configured/); + }); + + it("sends slack message successfully using env var token", async () => { + process.env.SOROKEEP_SLACK_TOKEN = "xoxb-env-token"; + await sendSlackAlert("C123", mockEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://slack.com/api/chat.postMessage"); + expect(init?.headers).toMatchObject({ + Authorization: "Bearer xoxb-env-token", + "Content-Type": "application/json" + }); + }); + + it("sends slack message successfully using config token", async () => { + mockLoadConfig.mockReturnValue({ slackToken: "xoxb-config-token" }); + await sendSlackAlert("C123", mockResourceEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws if slack API returns error", async () => { + process.env.SOROKEEP_SLACK_TOKEN = "token"; + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "invalid_auth" + } as Response); + + await expect(sendSlackAlert("C123", mockEvent)).rejects.toThrow(/Slack API request failed: HTTP 400/); + }); + + it("throws if slack API returns success but payload has error", async () => { + process.env.SOROKEEP_SLACK_TOKEN = "token"; + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: false, error: "channel_not_found" }) + } as Response); + + await expect(sendSlackAlert("C123", mockEvent)).rejects.toThrow(/Slack API error: channel_not_found/); + }); + }); + + describe("PagerDuty", () => { + it("sends pagerduty event successfully", async () => { + await sendPagerDutyAlert("integration-key", mockEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://events.pagerduty.com/v2/enqueue"); + expect(init?.headers).toMatchObject({ + "Content-Type": "application/json" + }); + }); + + it("handles resource alerts for pagerduty", async () => { + await sendPagerDutyAlert("integration-key", mockResourceEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("handles resolved alerts for pagerduty", async () => { + await sendPagerDutyAlert("integration-key", { + ...mockEvent, + type: "alert_resolved", + severity: "info", + message: "Resolved" + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws on non-202 status", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "invalid" + } as Response); + await expect(sendPagerDutyAlert("key", mockEvent)).rejects.toThrow(/PagerDuty API request failed: HTTP 400/); + }); + }); + + describe("Discord", () => { + it("sends discord webhook successfully", async () => { + await sendDiscordAlert("https://discord.com/api/webhooks/123/abc", mockEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends discord webhook for resource alerts", async () => { + await sendDiscordAlert("https://discord.com/api/webhooks/123/abc", mockResourceEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws on discord api error", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: async () => "webhook not found" + } as Response); + await expect(sendDiscordAlert("https://discord.com/api/webhooks/123/abc", mockEvent)).rejects.toThrow(/Discord webhook request failed: HTTP 404/); + }); + }); + + describe("Telegram", () => { + it("throws if no telegram bot token provided", async () => { + mockLoadConfig.mockReturnValue({}); + await expect(sendTelegramAlert("chat_id", mockEvent)).rejects.toThrow(/Telegram bot token not configured/); + }); + + it("sends telegram message successfully", async () => { + process.env.SOROKEEP_TELEGRAM_BOT_TOKEN = "bot-token"; + await sendTelegramAlert("chat_id", mockEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends telegram message for resource alerts", async () => { + process.env.SOROKEEP_TELEGRAM_BOT_TOKEN = "bot-token"; + await sendTelegramAlert("chat_id", mockResourceEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws on telegram api error", async () => { + process.env.SOROKEEP_TELEGRAM_BOT_TOKEN = "bot-token"; + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "invalid token" + } as Response); + await expect(sendTelegramAlert("chat_id", mockEvent)).rejects.toThrow(/Telegram API request failed: HTTP 401/); + }); + }); + + describe("Webhook", () => { + it("sends simple webhook successfully", async () => { + await sendWebhookAlert("http://custom.webhook", mockEvent, null); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("sends signed webhook successfully", async () => { + await sendWebhookAlert("http://custom.webhook", mockEvent, "secret-key"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [, init] = fetchSpy.mock.calls[0]; + expect(init?.headers).toHaveProperty("X-Sorokeep-Signature"); + }); + + it("throws on webhook error", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Error", + text: async () => "server error" + } as Response); + await expect(sendWebhookAlert("http://url", mockEvent, null)).rejects.toThrow(/Webhook delivery failed: HTTP 500 from http:\/\/url/); + }); + }); +}); diff --git a/src/alerts/keys.test.ts b/src/alerts/keys.test.ts new file mode 100644 index 00000000..617da932 --- /dev/null +++ b/src/alerts/keys.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ========================================== +// --- 1. CORE IMPLEMENTATION CODE --- +// ========================================== + +import { KeychainStore, SecureKeypairStore, KeysCliController } from "./keys"; + +// ========================================== +// --- 2. TDD AUTOMATED TEST SUITE --- +// ========================================== + +describe("TDD - Local OS Keychain & Safe CLI Key Management Engine", () => { + let mockKeytar: any; + let secureStore: SecureKeypairStore; + let cliController: KeysCliController; + + // In-memory fake database tracking our OS keychain simulations cleanly + let fakeKeychainDb: Array<{ account: string; value: string }>; + + beforeEach(() => { + fakeKeychainDb = []; + + // Step 1: Build robust spy mocks matching native keytar interfaces + mockKeytar = { + setPassword: vi.fn().mockImplementation(async (service, account, password) => { + fakeKeychainDb.push({ account, value: password }); + return null; + }), + findCredentials: vi.fn().mockImplementation(async (service) => { + return fakeKeychainDb.map(item => ({ account: item.account, password: item.value })); + }) + }; + + secureStore = new SecureKeypairStore(mockKeytar); + cliController = new KeysCliController(secureStore); + }); + + it("should successfully save a secret key into the native system credentials database", async () => { + // Act + const targetLabel = "deployer-keypair"; + const rawSecretSeed = "SCON3...SOROBAN...SECRET...SEED"; + + const saveResult = await secureStore.saveKey(targetLabel, rawSecretSeed); + + // Assert: Verify internal keytar execution signals + expect(saveResult).toBe(true); + expect(mockKeytar.setPassword).toHaveBeenCalledTimes(1); + expect(mockKeytar.setPassword).toHaveBeenCalledWith("sorokeep-keys", targetLabel, rawSecretSeed); + + // Confirm it exists in our simulated OS vault + expect(fakeKeychainDb[0].value).toBe(rawSecretSeed); + }); + + it("should list registered key labels through CLI command without showing raw values", async () => { + // Arrange: Populate keys inside the credential vault + fakeKeychainDb.push({ account: "production-admin", value: "SECRET_AAA" }); + fakeKeychainDb.push({ account: "staging-validator", value: "SECRET_BBB" }); + + // Act: Invoke listing operations + const reportedLabels = await cliController.handleListKeysCommand(); + + // Assert: Verify compliance with security criteria + expect(reportedLabels).toContain("production-admin"); + expect(reportedLabels).toContain("staging-validator"); + expect(reportedLabels.length).toBe(2); + + // CRITICAL SECURITY ASSERTION: Make sure raw private secrets are completely hidden + expect(reportedLabels).not.toContain("SECRET_AAA"); + expect(reportedLabels).not.toContain("SECRET_BBB"); + }); + + it("should fail gracefully when attempting to save an empty key configuration parameters", async () => { + // Act & Assert defensive tracking boundaries + await expect(secureStore.saveKey("", "SCON123")).rejects.toThrow(); + await expect(secureStore.saveKey("valid-name", "")).rejects.toThrow(); + + expect(mockKeytar.setPassword).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/alerts/simulation_cache.test.ts b/src/alerts/simulation_cache.test.ts new file mode 100644 index 00000000..8a7e5a8c --- /dev/null +++ b/src/alerts/simulation_cache.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ========================================== +// --- 1. CORE IMPLEMENTATION CODE --- +// ========================================== + +export interface SimulationResult { + cpuInstructions: number; + memoryBytes: number; + minResourceFee: number; +} + +export interface CacheEntry { + result: SimulationResult; + contractWasmHash: string; + contractInstanceId: string; +} + +/** + * Footprint-keyed local simulation cache manager + */ +export class SimulationCacheManager { + // Primary storage map: keyed on contract footprint hash strings + private cache = new Map(); + // Deduplicates in-flight RPC simulation requests for the same footprint + private pending = new Map>(); + public rpcCallCount = 0; // Tracks live RPC pass-through hits for metrics checking + + /** + * Retrieves simulation estimates from cache, deduplicates in-flight calls, + * or executes the fallback transaction simulation function on cache misses or state invalidations. + */ + async getSimulation( + footprintHash: string, + currentWasmHash: string, + contractInstanceId: string, + simulationFallback: () => Promise + ): Promise { + const cachedEntry = this.cache.get(footprintHash); + + // CRITICAL REQUIREMENT: If cached entry exists AND contract state (WASM + Instance) matches, return it + if ( + cachedEntry && + cachedEntry.contractWasmHash === currentWasmHash && + cachedEntry.contractInstanceId === contractInstanceId + ) { + return cachedEntry.result; + } + + // Deduplicate in-flight concurrent requests + if (this.pending.has(footprintHash)) { + return this.pending.get(footprintHash)!; + } + + // Cache Miss or Invalidation: Execute live transaction simulation fallback + this.rpcCallCount++; + const promise = simulationFallback() + .then((freshResult) => { + // Cache the fresh result alongside its matching validation state tokens + this.cache.set(footprintHash, { + result: freshResult, + contractWasmHash: currentWasmHash, + contractInstanceId, + }); + this.pending.delete(footprintHash); + return freshResult; + }) + .catch((err) => { + this.pending.delete(footprintHash); + throw err; + }); + + this.pending.set(footprintHash, promise); + return promise; + } + + /** + * Exposes internal storage size metrics for validation tracking + */ + getCacheSize(): number { + return this.cache.size; + } +} + +// ========================================== +// --- 2. TDD AUTOMATED TEST SUITE --- +// ========================================== + +describe("TDD - Local Soroban Transaction Simulation Cache Engine", () => { + let cacheManager: SimulationCacheManager; + let mockSimulationFallback: any; + let standardResult: SimulationResult; + + beforeEach(() => { + cacheManager = new SimulationCacheManager(); + + standardResult = { + cpuInstructions: 154000, + memoryBytes: 4096, + minResourceFee: 10000, + }; + + // Spy tracking for the simulated RPC network fallback function + mockSimulationFallback = vi.fn().mockResolvedValue(standardResult); + }); + + it("should return cached resource estimates on duplicate calls with matching footprints", async () => { + const footprintHash = "footprint_hash_abc_123"; + const wasmHash = "wasm_state_v1"; + const instanceId = "instance_state_v1"; + + // First Call: Cache miss, should fire live RPC invocation pass-through + const run1 = await cacheManager.getSimulation(footprintHash, wasmHash, instanceId, mockSimulationFallback); + + // Second Call: Target duplicate footprint hit, should read directly from in-memory cache + const run2 = await cacheManager.getSimulation(footprintHash, wasmHash, instanceId, mockSimulationFallback); + + // Assert: Verify results match perfectly + expect(run1).toEqual(standardResult); + expect(run2).toEqual(standardResult); + + // CRITICAL CRITERIA ASSERTION: Confirm network traffic did not duplicate + expect(mockSimulationFallback).toHaveBeenCalledTimes(1); + expect(cacheManager.rpcCallCount).toBe(1); + }); + + it("should invalidate cache and trigger a fresh simulation when footprints, WASMs, or instances modify", async () => { + const footprintHash = "footprint_hash_abc_123"; + const initialWasmHash = "wasm_state_v1"; + const upgradedWasmHash = "wasm_state_v2_upgraded"; + const instanceId = "instance_state_v1"; + const upgradedInstanceId = "instance_state_v2_upgraded"; + + // Step 1: Prime cache repository tracking metrics + await cacheManager.getSimulation(footprintHash, initialWasmHash, instanceId, mockSimulationFallback); + expect(cacheManager.rpcCallCount).toBe(1); + + // Step 2: Simulate another transaction layout execution pass targeting an updated contract WASM state + const upgradedWasmResult: SimulationResult = { ...standardResult, cpuInstructions: 280000 }; + mockSimulationFallback.mockResolvedValueOnce(upgradedWasmResult); + + const runWithInvalidatedWasm = await cacheManager.getSimulation( + footprintHash, + upgradedWasmHash, // State signature mismatch triggers invalidation path + instanceId, + mockSimulationFallback + ); + + expect(runWithInvalidatedWasm.cpuInstructions).toBe(280000); + expect(mockSimulationFallback).toHaveBeenCalledTimes(2); + expect(cacheManager.rpcCallCount).toBe(2); + + // Step 3: Simulate targeting an updated contract instance state + const upgradedInstanceResult: SimulationResult = { ...standardResult, cpuInstructions: 320000 }; + mockSimulationFallback.mockResolvedValueOnce(upgradedInstanceResult); + + const runWithInvalidatedInstance = await cacheManager.getSimulation( + footprintHash, + upgradedWasmHash, + upgradedInstanceId, // Instance signature mismatch triggers invalidation path + mockSimulationFallback + ); + + expect(runWithInvalidatedInstance.cpuInstructions).toBe(320000); + expect(mockSimulationFallback).toHaveBeenCalledTimes(3); + expect(cacheManager.rpcCallCount).toBe(3); + }); + + it("should deduplicate in-flight concurrent requests for the same footprint", async () => { + const footprintHash = "footprint_hash_concurrent"; + const wasmHash = "wasm_state_v1"; + const instanceId = "instance_state_v1"; + + let resolveSimulation: (val: SimulationResult) => void; + mockSimulationFallback.mockImplementation(() => { + return new Promise((resolve) => { + resolveSimulation = resolve; + }); + }); + + const promise1 = cacheManager.getSimulation(footprintHash, wasmHash, instanceId, mockSimulationFallback); + const promise2 = cacheManager.getSimulation(footprintHash, wasmHash, instanceId, mockSimulationFallback); + const promise3 = cacheManager.getSimulation(footprintHash, wasmHash, instanceId, mockSimulationFallback); + + expect(mockSimulationFallback).toHaveBeenCalledTimes(1); + expect(cacheManager.rpcCallCount).toBe(1); + + // Resolve the single shared pending promise + resolveSimulation!(standardResult); + + const results = await Promise.all([promise1, promise2, promise3]); + for (const result of results) { + expect(result).toEqual(standardResult); + } + + // Should still only have one RPC call made + expect(mockSimulationFallback).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/cli/program.ts b/src/cli/program.ts index ae582fa1..2e3dad41 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { registerAlertChannel } from "../alerts/registry.js"; +import { setYesOverride } from "../utils/prompt.js"; import { registerWatchCommand } from "../commands/watch.js"; import { registerStatusCommand } from "../commands/status.js"; import { registerCheckCommand } from "../commands/check.js"; @@ -57,6 +58,7 @@ export function createProgram() { "Sorokeep — The missing operations layer for deployed Soroban smart contracts", ) .version("0.1.2") + .option("-y, --yes", "Skip confirmation prompts on destructive commands") .option("--extension-jitter-ms ", "Jitter window in ms applied to extension submissions", parseInt) .option( "--channel-plugin ", @@ -65,6 +67,10 @@ export function createProgram() { [] as string[], ); + program.hook("preAction", (thisCommand) => { + setYesOverride(thisCommand.optsWithGlobals().yes ?? false); + }); + program.hook("preAction", async (_thisCommand, actionCommand) => { if (channelPluginsLoaded) return; channelPluginsLoaded = true; diff --git a/src/commands/watch.ts b/src/commands/watch.ts index f398ce7a..62c3831b 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,6 +1,5 @@ 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"; @@ -11,6 +10,7 @@ import { formatTimeToCloseLedger, statusIndicator, } from "../utils/formatting.js"; +import { confirmPrompt } from "../utils/prompt.js"; import { watchContract } from "../core/watch.js"; import { loadWatchContractsFile } from "../utils/watch-config.js"; @@ -169,8 +169,7 @@ export const registerWatchCommand = (program: Command): void => { 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 }) => { + .action(async (contractId: string) => { try { const db = getDatabase(); const contract = getContract(db, contractId); @@ -186,25 +185,14 @@ export const registerWatchCommand = (program: Command): void => { console.log(chalk.green(`Successfully unwatched contract ${formatContractID(contractId)}.`)); }; - if (options.yes) { + const confirmed = await confirmPrompt( + chalk.yellow(`Are you sure you want to unwatch ${formatContractID(contractId)}? All associated logs, alerts, and policies will be permanently deleted. (y/N): `), + ); + + if (confirmed) { 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."); - } - } - ); + console.log("Unwatch cancelled."); } } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 00000000..c639f867 --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,42 @@ +import readline from "node:readline"; + +let _yesOverride = false; + +/** + * Set the global --yes override flag. + * When true, confirmPrompt returns true immediately without user interaction. + */ +export function setYesOverride(value: boolean): void { + _yesOverride = value; +} + +/** + * Get the current value of the --yes override flag. + */ +export function getYesOverride(): boolean { + return _yesOverride; +} + +/** + * Prompt the user for confirmation. + * Returns true immediately when --yes override is active. + * Otherwise, prompts via readline and returns true for 'y'/'yes' answers. + */ +export async function confirmPrompt(question: string): Promise { + if (_yesOverride) { + return true; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === "y" || normalized === "yes"); + }); + }); +} diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index fc4e3c12..101f4b01 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -5,7 +5,7 @@ import * as dbLib from "../../src/db/database"; import * as watchCore from "../../src/core/watch"; import * as watchConfig from "../../src/utils/watch-config"; import * as repositories from "../../src/db/repositories"; -import readline from "node:readline"; +import * as prompt from "../../src/utils/prompt"; vi.mock("../../src/db/database"); vi.mock("../../src/core/watch"); @@ -249,8 +249,7 @@ describe("Unwatch Command CLI", () => { let program: Command; let mockExit: any; let mockLog: any; - let unwatchActionFn: (contractId: string, options: any) => Promise; - let mockRl: any; + let unwatchActionFn: (contractId: string) => Promise; beforeEach(() => { program = new Command(); @@ -268,56 +267,48 @@ describe("Unwatch Command CLI", () => { 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(() => { + prompt.setYesOverride(false); vi.restoreAllMocks(); }); it("exits with 1 when no contract id provided", async () => { // commander requires it, but if bypassed: - await unwatchActionFn(undefined as any, {}); + 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", {}); + await unwatchActionFn("CDEF1234"); expect(mockExit).toHaveBeenCalledWith(1); expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("not being watched")); }); - it("deletes contract directly when --yes is passed", async () => { + it("deletes contract directly when --yes is active (via setYesOverride)", async () => { + prompt.setYesOverride(true); vi.mocked(repositories.getContract).mockReturnValue({ id: "CDEF1234" } as any); - await unwatchActionFn("CDEF1234", { yes: true }); + await unwatchActionFn("CDEF1234"); 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 () => { + it("prompts for confirmation when --yes is not active and deletes if confirmed", async () => { + vi.spyOn(prompt, "confirmPrompt").mockResolvedValue(true); 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(); + await unwatchActionFn("CDEF1234"); + expect(prompt.confirmPrompt).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.spyOn(prompt, "confirmPrompt").mockResolvedValue(false); 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(); + await unwatchActionFn("CDEF1234"); + expect(prompt.confirmPrompt).toHaveBeenCalled(); expect(repositories.deleteContract).not.toHaveBeenCalled(); expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Unwatch cancelled")); }); diff --git a/tests/utils/prompt.test.ts b/tests/utils/prompt.test.ts new file mode 100644 index 00000000..5dddd4b4 --- /dev/null +++ b/tests/utils/prompt.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { confirmPrompt, setYesOverride, getYesOverride } from "../../src/utils/prompt"; +import readline from "node:readline"; + +describe("confirmPrompt", () => { + let mockRl: { question: any; close: any }; + + beforeEach(() => { + // Reset override before each test + setYesOverride(false); + + mockRl = { question: vi.fn(), close: vi.fn() }; + vi.spyOn(readline, "createInterface").mockReturnValue(mockRl as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("when --yes override is active", () => { + it("returns true immediately without invoking readline", async () => { + setYesOverride(true); + + const result = await confirmPrompt("Delete everything?"); + + expect(result).toBe(true); + expect(readline.createInterface).not.toHaveBeenCalled(); + }); + + it("returns true even when called multiple times", async () => { + setYesOverride(true); + + const r1 = await confirmPrompt("Question 1?"); + const r2 = await confirmPrompt("Question 2?"); + const r3 = await confirmPrompt("Question 3?"); + + expect(r1).toBe(true); + expect(r2).toBe(true); + expect(r3).toBe(true); + expect(readline.createInterface).not.toHaveBeenCalled(); + }); + }); + + describe("when --yes override is NOT active", () => { + it("prompts the user via readline with the given question", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("yes"); + }, + ); + + const result = await confirmPrompt("Delete everything? (y/N): "); + + expect(readline.createInterface).toHaveBeenCalledTimes(1); + expect(mockRl.question).toHaveBeenCalledWith( + "Delete everything? (y/N): ", + expect.any(Function), + ); + expect(result).toBe(true); + }); + + it("returns false when user answers 'no'", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("no"); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(false); + }); + + it("returns false when user answers 'n'", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("n"); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(false); + }); + + it("returns true for case-insensitive 'Y' answer", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("Y"); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(true); + }); + + it("returns true for case-insensitive 'YES' answer", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("YES"); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(true); + }); + + it("returns false for any non-affirmative answer", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb("maybe"); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(false); + }); + + it("returns false for empty answer", async () => { + mockRl.question.mockImplementation( + (_query: string, cb: (answer: string) => void) => { + cb(""); + }, + ); + + const result = await confirmPrompt("Proceed? (y/N): "); + + expect(result).toBe(false); + }); + }); +}); + +describe("setYesOverride / getYesOverride", () => { + afterEach(() => { + setYesOverride(false); + }); + + it("getYesOverride returns false by default", () => { + expect(getYesOverride()).toBe(false); + }); + + it("getYesOverride returns true after setYesOverride(true)", () => { + setYesOverride(true); + expect(getYesOverride()).toBe(true); + }); + + it("getYesOverride returns false after setYesOverride(false)", () => { + setYesOverride(true); + setYesOverride(false); + expect(getYesOverride()).toBe(false); + }); +});