From e8333b29b9e75ea3c2ee848855068ce9a776fda0 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Tue, 23 Jun 2026 15:42:51 -0400 Subject: [PATCH] Adapt to quicknode-sdk 0.3: require JWT kid and webhook compression, support ByList templates SDK 0.3 made several request fields non-optional and reshaped webhook template args: - CreateJwtRequest.kid is now required, so `--kid` is required on `qn endpoint security jwt add`. - WebhookDestinationAttributes.compression is now required, so `--compression` is required on `qn webhook create`; update/update-template require it only when `--url` is supplied. - Each TemplateArgs variant now wraps an Inline/ByList input enum. Added `--*-list-name` flags so a template can reference a saved list instead of inline values; supply the inline flag or the list-name flag, not both. - evm-contract-events event_hashes is now a required Vec. Updates the embedded `agent context` guide. Adds 4 webhook tests (ByList, evm-abi ByList, inline+list conflict, missing compression) and 2 JWT tests (add-with-kid, requires-kid). Full suite green. --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/commands/agent/context.md | 8 +- src/commands/endpoint/security.rs | 2 +- src/commands/webhook/actions.rs | 346 +++++++++++++++++++++--------- src/commands/webhook/mod.rs | 29 ++- tests/endpoint.rs | 59 ++++- tests/webhooks.rs | 153 ++++++++++++- 8 files changed, 493 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85091f3..e0e304c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,9 @@ dependencies = [ [[package]] name = "quicknode-sdk" -version = "0.1.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6427dcb05189c051bdd98279fcbb5d0064a97669b856ba599d32b2aab018e35" +checksum = "f91ad3da8e75511dfbb2ae1d81eb914c70a8187f58369164c56a321cb1a20799" dependencies = [ "config", "reqwest 0.13.4", diff --git a/Cargo.toml b/Cargo.toml index 6802d9b..b1aaa69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.1" +quicknode-sdk = "0.3" clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap_complete = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 75baff0..e7dc5d7 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -145,12 +145,18 @@ qn stream activate ```sh qn webhook create --name wallet-watch --network ethereum-mainnet \ - --url https://hook.example.com --template evm-wallet \ + --url https://hook.example.com --compression none --template evm-wallet \ --wallet 0xabc... # → id qn webhook show # inspect before activating qn webhook activate ``` +`--compression` (`gzip` or `none`) is required on create. Instead of inline +values, a template can reference a saved list with the matching +`--*-list-name` flag (e.g. `--wallets-list-name`, `--accounts-list-name`, +`--contracts-list-name`); supply either the inline flag or the list-name flag, +not both. + **KV put / get / list:** ```sh diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index 74b0b1b..45f449c 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -167,7 +167,7 @@ pub struct JwtAddArgs { pub public_key_file: Option, /// Key id (`kid`). #[arg(long)] - pub kid: Option, + pub kid: String, /// Human-readable name. #[arg(long)] pub name: Option, diff --git a/src/commands/webhook/actions.rs b/src/commands/webhook/actions.rs index 9fa81c6..bf5a5a3 100644 --- a/src/commands/webhook/actions.rs +++ b/src/commands/webhook/actions.rs @@ -3,11 +3,18 @@ use std::path::PathBuf; use quicknode_sdk::webhooks::{ - ActivateWebhookParams, BitcoinWalletFilterTemplate, CreateWebhookFromTemplateParams, - EvmAbiFilterTemplate, EvmContractEventsTemplate, EvmWalletFilterTemplate, GetWebhooksParams, - HyperliquidWalletEventsFilterTemplate, SolanaWalletFilterTemplate, + ActivateWebhookParams, BitcoinWalletFilterByListTemplate, BitcoinWalletFilterInput, + BitcoinWalletFilterTemplate, CreateWebhookFromTemplateParams, EvmAbiFilterByListTemplate, + EvmAbiFilterInput, EvmAbiFilterTemplate, EvmContractEventsByListTemplate, + EvmContractEventsInput, EvmContractEventsTemplate, EvmWalletFilterByListTemplate, + EvmWalletFilterInput, EvmWalletFilterTemplate, GetWebhooksParams, + HyperliquidWalletEventsFilterByListTemplate, HyperliquidWalletEventsFilterInput, + HyperliquidWalletEventsFilterTemplate, SolanaWalletFilterByListTemplate, + SolanaWalletFilterInput, SolanaWalletFilterTemplate, + StellarWalletTransactionsFilterByListTemplate, StellarWalletTransactionsFilterInput, StellarWalletTransactionsFilterTemplate, TemplateArgs, UpdateWebhookParams, - UpdateWebhookTemplateParams, WebhookDestinationAttributes, XrplWalletFilterTemplate, + UpdateWebhookTemplateParams, WebhookDestinationAttributes, XrplWalletFilterByListTemplate, + XrplWalletFilterInput, XrplWalletFilterTemplate, }; use super::render::{WebhookView, WebhooksListView}; @@ -35,15 +42,19 @@ pub(super) async fn show(id: &str, ctx: Ctx) -> Result<(), CliError> { } pub(super) async fn create(a: CreateArgs, ctx: Ctx) -> Result<(), CliError> { - let template_args = build_template_args( - a.template, - a.wallets, - a.accounts, - a.contracts, - a.event_hashes, - a.abi, - a.abi_file, - )?; + let template_args = build_template_args(TemplateInputs { + kind: a.template, + wallets: a.wallets, + accounts: a.accounts, + contracts: a.contracts, + event_hashes: a.event_hashes, + abi: a.abi, + abi_file: a.abi_file, + wallets_list_name: a.wallets_list_name, + accounts_list_name: a.accounts_list_name, + contracts_list_name: a.contracts_list_name, + event_hashes_list_name: a.event_hashes_list_name, + })?; let params = CreateWebhookFromTemplateParams { name: a.name, network: a.network, @@ -76,11 +87,7 @@ pub(super) async fn update(a: UpdateArgs, ctx: Ctx) -> Result<(), CliError> { )); } let destination = match a.url { - Some(url) => Some(WebhookDestinationAttributes { - url, - security_token: a.security_token, - compression: a.compression, - }), + Some(url) => Some(build_destination(url, a.security_token, a.compression)?), None if a.security_token.is_some() || a.compression.is_some() => { return Err(CliError::Arg( "to change destination fields, also supply --url".into(), @@ -99,20 +106,23 @@ pub(super) async fn update(a: UpdateArgs, ctx: Ctx) -> Result<(), CliError> { } pub(super) async fn update_template(a: UpdateTemplateArgs, ctx: Ctx) -> Result<(), CliError> { - let template_args = build_template_args( - a.template, - a.wallets, - a.accounts, - a.contracts, - a.event_hashes, - a.abi, - a.abi_file, - )?; - let destination = a.url.map(|url| WebhookDestinationAttributes { - url, - security_token: a.security_token, - compression: a.compression, - }); + let template_args = build_template_args(TemplateInputs { + kind: a.template, + wallets: a.wallets, + accounts: a.accounts, + contracts: a.contracts, + event_hashes: a.event_hashes, + abi: a.abi, + abi_file: a.abi_file, + wallets_list_name: a.wallets_list_name, + accounts_list_name: a.accounts_list_name, + contracts_list_name: a.contracts_list_name, + event_hashes_list_name: a.event_hashes_list_name, + })?; + let destination = match a.url { + Some(url) => Some(build_destination(url, a.security_token, a.compression)?), + None => None, + }; let params = UpdateWebhookTemplateParams { name: a.name, notification_email: a.notification_email, @@ -175,7 +185,9 @@ pub(super) async fn enabled_count(ctx: Ctx) -> Result<(), CliError> { } } -fn build_template_args( +/// All template-selection inputs gathered from the CLI flags. Each template +/// reads the subset it needs; the rest stay unused for that variant. +struct TemplateInputs { kind: TemplateKind, wallets: Vec, accounts: Vec, @@ -183,88 +195,218 @@ fn build_template_args( event_hashes: Vec, abi: Option, abi_file: Option, -) -> Result { - let event_hashes_opt = if event_hashes.is_empty() { - None - } else { - Some(event_hashes) - }; - match kind { - TemplateKind::EvmWallet => { - require_wallets(&wallets)?; - Ok(TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate { - wallets, - })) - } - TemplateKind::SolanaWallet => { - if accounts.is_empty() { - return Err(CliError::Arg("supply at least one --account".into())); + wallets_list_name: Option, + accounts_list_name: Option, + contracts_list_name: Option, + event_hashes_list_name: Option, +} + +/// Resolved value source for a single template field: either inline values +/// supplied directly, or the name of a saved list to reference. +enum FilterSource { + Inline(Vec), + ByList(String), +} + +fn build_template_args(t: TemplateInputs) -> Result { + match t.kind { + TemplateKind::EvmWallet => Ok(match wallets_source(t.wallets, t.wallets_list_name)? { + FilterSource::Inline(wallets) => TemplateArgs::EvmWalletFilter( + EvmWalletFilterInput::Inline(EvmWalletFilterTemplate { wallets }), + ), + FilterSource::ByList(wallets_list_name) => TemplateArgs::EvmWalletFilter( + EvmWalletFilterInput::ByList(EvmWalletFilterByListTemplate { wallets_list_name }), + ), + }), + TemplateKind::BitcoinWallet => Ok(match wallets_source(t.wallets, t.wallets_list_name)? { + FilterSource::Inline(wallets) => TemplateArgs::BitcoinWalletFilter( + BitcoinWalletFilterInput::Inline(BitcoinWalletFilterTemplate { wallets }), + ), + FilterSource::ByList(wallets_list_name) => { + TemplateArgs::BitcoinWalletFilter(BitcoinWalletFilterInput::ByList( + BitcoinWalletFilterByListTemplate { wallets_list_name }, + )) } - Ok(TemplateArgs::SolanaWalletFilter( - SolanaWalletFilterTemplate { accounts }, - )) - } - TemplateKind::BitcoinWallet => { - require_wallets(&wallets)?; - Ok(TemplateArgs::BitcoinWalletFilter( - BitcoinWalletFilterTemplate { wallets }, - )) - } - TemplateKind::XrplWallet => { - require_wallets(&wallets)?; - Ok(TemplateArgs::XrplWalletFilter(XrplWalletFilterTemplate { - wallets, - })) - } + }), + TemplateKind::XrplWallet => Ok(match wallets_source(t.wallets, t.wallets_list_name)? { + FilterSource::Inline(wallets) => TemplateArgs::XrplWalletFilter( + XrplWalletFilterInput::Inline(XrplWalletFilterTemplate { wallets }), + ), + FilterSource::ByList(wallets_list_name) => TemplateArgs::XrplWalletFilter( + XrplWalletFilterInput::ByList(XrplWalletFilterByListTemplate { wallets_list_name }), + ), + }), TemplateKind::HyperliquidWalletEvents => { - require_wallets(&wallets)?; - Ok(TemplateArgs::HyperliquidWalletEventsFilter( - HyperliquidWalletEventsFilterTemplate { wallets }, - )) + Ok(match wallets_source(t.wallets, t.wallets_list_name)? { + FilterSource::Inline(wallets) => TemplateArgs::HyperliquidWalletEventsFilter( + HyperliquidWalletEventsFilterInput::Inline( + HyperliquidWalletEventsFilterTemplate { wallets }, + ), + ), + FilterSource::ByList(wallets_list_name) => { + TemplateArgs::HyperliquidWalletEventsFilter( + HyperliquidWalletEventsFilterInput::ByList( + HyperliquidWalletEventsFilterByListTemplate { wallets_list_name }, + ), + ) + } + }) } TemplateKind::StellarWalletTransactions => { - require_wallets(&wallets)?; - Ok(TemplateArgs::StellarWalletTransactionsSourceAccountFilter( - StellarWalletTransactionsFilterTemplate { wallets }, - )) + Ok(match wallets_source(t.wallets, t.wallets_list_name)? { + FilterSource::Inline(wallets) => { + TemplateArgs::StellarWalletTransactionsSourceAccountFilter( + StellarWalletTransactionsFilterInput::Inline( + StellarWalletTransactionsFilterTemplate { wallets }, + ), + ) + } + FilterSource::ByList(wallets_list_name) => { + TemplateArgs::StellarWalletTransactionsSourceAccountFilter( + StellarWalletTransactionsFilterInput::ByList( + StellarWalletTransactionsFilterByListTemplate { wallets_list_name }, + ), + ) + } + }) + } + TemplateKind::SolanaWallet => { + let source = filter_source( + t.accounts, + t.accounts_list_name, + "--account", + "--accounts-list-name", + )?; + Ok(match source { + FilterSource::Inline(accounts) => TemplateArgs::SolanaWalletFilter( + SolanaWalletFilterInput::Inline(SolanaWalletFilterTemplate { accounts }), + ), + FilterSource::ByList(accounts_list_name) => { + TemplateArgs::SolanaWalletFilter(SolanaWalletFilterInput::ByList( + SolanaWalletFilterByListTemplate { accounts_list_name }, + )) + } + }) } TemplateKind::EvmContractEvents => { - if contracts.is_empty() { - return Err(CliError::Arg("supply at least one --contract".into())); - } - Ok(TemplateArgs::EvmContractEvents(EvmContractEventsTemplate { - contracts, - event_hashes: event_hashes_opt, - })) + let source = filter_source( + t.contracts, + t.contracts_list_name, + "--contract", + "--contracts-list-name", + )?; + Ok(match source { + FilterSource::Inline(contracts) => TemplateArgs::EvmContractEvents( + EvmContractEventsInput::Inline(EvmContractEventsTemplate { + contracts, + event_hashes: t.event_hashes, + }), + ), + FilterSource::ByList(contracts_list_name) => TemplateArgs::EvmContractEvents( + EvmContractEventsInput::ByList(EvmContractEventsByListTemplate { + contracts_list_name, + event_hashes_list_name: t.event_hashes_list_name, + }), + ), + }) } TemplateKind::EvmAbi => { - if contracts.is_empty() { - return Err(CliError::Arg("supply at least one --contract".into())); - } - let abi_text = match (abi, abi_file) { - (Some(s), None) => s, - (None, Some(p)) => std::fs::read_to_string(&p)?, - (None, None) => { - return Err(CliError::Arg("supply --abi or --abi-file".into())); + let abi_text = read_abi(t.abi, t.abi_file)?; + // The ABI is always inline content; only the contracts can come + // from a saved list, which selects the ByList variant. + Ok(match t.contracts_list_name { + Some(contracts_list_name) => { + if !t.contracts.is_empty() { + return Err(CliError::Arg( + "supply either --contract or --contracts-list-name, not both".into(), + )); + } + TemplateArgs::EvmAbiFilter(EvmAbiFilterInput::ByList( + EvmAbiFilterByListTemplate { + abi_json: abi_text, + contracts_list_name: Some(contracts_list_name), + }, + )) } - (Some(_), Some(_)) => { - return Err(CliError::Arg( - "supply only one of --abi or --abi-file".into(), - )); + None => { + if t.contracts.is_empty() { + return Err(CliError::Arg( + "supply at least one --contract (or --contracts-list-name)".into(), + )); + } + TemplateArgs::EvmAbiFilter(EvmAbiFilterInput::Inline(EvmAbiFilterTemplate { + abi: abi_text, + contracts: t.contracts, + })) } - }; - Ok(TemplateArgs::EvmAbiFilter(EvmAbiFilterTemplate { - abi: abi_text, - contracts, - })) + }) } } } -fn require_wallets(wallets: &[String]) -> Result<(), CliError> { - if wallets.is_empty() { - Err(CliError::Arg("supply at least one --wallet".into())) - } else { - Ok(()) +/// Wallet-style templates all key off the same `--wallet` / `--wallets-list-name` pair. +fn wallets_source( + wallets: Vec, + wallets_list_name: Option, +) -> Result { + filter_source( + wallets, + wallets_list_name, + "--wallet", + "--wallets-list-name", + ) +} + +/// Resolve an inline-values-vs-saved-list choice, rejecting "both" and "neither". +fn filter_source( + inline: Vec, + list_name: Option, + inline_flag: &str, + list_flag: &str, +) -> Result { + match list_name { + Some(name) => { + if !inline.is_empty() { + return Err(CliError::Arg(format!( + "supply either {inline_flag} or {list_flag}, not both" + ))); + } + Ok(FilterSource::ByList(name)) + } + None => { + if inline.is_empty() { + return Err(CliError::Arg(format!( + "supply at least one {inline_flag} (or {list_flag})" + ))); + } + Ok(FilterSource::Inline(inline)) + } } } + +fn read_abi(abi: Option, abi_file: Option) -> Result { + match (abi, abi_file) { + (Some(s), None) => Ok(s), + (None, Some(p)) => Ok(std::fs::read_to_string(&p)?), + (None, None) => Err(CliError::Arg("supply --abi or --abi-file".into())), + (Some(_), Some(_)) => Err(CliError::Arg( + "supply only one of --abi or --abi-file".into(), + )), + } +} + +/// Build a destination from an explicit URL, requiring compression (the API +/// needs a non-optional value whenever a destination is sent). +fn build_destination( + url: String, + security_token: Option, + compression: Option, +) -> Result { + let compression = compression + .ok_or_else(|| CliError::Arg("--url requires --compression (`gzip` or `none`)".into()))?; + Ok(WebhookDestinationAttributes { + url, + security_token, + compression, + }) +} diff --git a/src/commands/webhook/mod.rs b/src/commands/webhook/mod.rs index 5283596..8cbd201 100644 --- a/src/commands/webhook/mod.rs +++ b/src/commands/webhook/mod.rs @@ -90,7 +90,7 @@ pub struct CreateArgs { pub security_token: Option, /// Payload compression (`gzip` or `none`). #[arg(long)] - pub compression: Option, + pub compression: String, /// Optional notification email. #[arg(long)] pub notification_email: Option, @@ -117,6 +117,19 @@ pub struct CreateArgs { /// For abi template: path to a file with the contract ABI JSON. #[arg(long)] pub abi_file: Option, + + /// For wallet-style templates: reference a saved wallets list by name (instead of `--wallet`). + #[arg(long)] + pub wallets_list_name: Option, + /// For Solana wallet template: reference a saved accounts list by name (instead of `--account`). + #[arg(long)] + pub accounts_list_name: Option, + /// For contract-events and abi templates: reference a saved contracts list by name (instead of `--contract`). + #[arg(long)] + pub contracts_list_name: Option, + /// For contract-events template: reference a saved event-hashes list by name. + #[arg(long)] + pub event_hashes_list_name: Option, } #[derive(Debug, ClapArgs)] @@ -156,6 +169,19 @@ pub struct UpdateTemplateArgs { #[arg(long)] pub abi_file: Option, + /// For wallet-style templates: reference a saved wallets list by name (instead of `--wallet`). + #[arg(long)] + pub wallets_list_name: Option, + /// For Solana wallet template: reference a saved accounts list by name (instead of `--account`). + #[arg(long)] + pub accounts_list_name: Option, + /// For contract-events and abi templates: reference a saved contracts list by name (instead of `--contract`). + #[arg(long)] + pub contracts_list_name: Option, + /// For contract-events template: reference a saved event-hashes list by name. + #[arg(long)] + pub event_hashes_list_name: Option, + /// Optionally also rename. #[arg(long)] pub name: Option, @@ -165,6 +191,7 @@ pub struct UpdateTemplateArgs { pub url: Option, #[arg(long)] pub security_token: Option, + /// Payload compression (`gzip` or `none`); required when `--url` is supplied. #[arg(long)] pub compression: Option, } diff --git a/tests/endpoint.rs b/tests/endpoint.rs index 6d7956b..9260c32 100644 --- a/tests/endpoint.rs +++ b/tests/endpoint.rs @@ -4,7 +4,7 @@ mod common; use common::run_qn; use serde_json::json; -use wiremock::matchers::{body_json, header, method, path}; +use wiremock::matchers::{body_json, body_partial_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn endpoint_payload(id: &str) -> serde_json::Value { @@ -781,6 +781,63 @@ async fn endpoint_security_jwt_remove_without_yes_sends_nothing() { assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); } +#[tokio::test] +async fn endpoint_security_jwt_add_with_kid() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/jwts")) + .and(body_partial_json( + json!({ "public_key": "pk", "kid": "kid-1" }), + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "jwt", + "add", + "ep-1", + "--public-key", + "pk", + "--kid", + "kid-1", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_jwt_add_requires_kid() { + let server = MockServer::start().await; + // --kid is required; the request never fires. + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/jwts")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "jwt", + "add", + "ep-1", + "--public-key", + "pk", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "stderr={}", out.stderr); + assert!(out.stderr.contains("kid"), "stderr={}", out.stderr); +} + #[tokio::test] async fn endpoint_security_domain_mask_remove_with_yes() { let server = MockServer::start().await; diff --git a/tests/webhooks.rs b/tests/webhooks.rs index 1728d43..c89da7f 100644 --- a/tests/webhooks.rs +++ b/tests/webhooks.rs @@ -41,7 +41,7 @@ async fn create_webhook_evm_wallet() { .and(body_partial_json(json!({ "name": "w1", "network": "ethereum-mainnet", - "destination_attributes": { "url": "https://hook.example/x" }, + "destination_attributes": { "url": "https://hook.example/x", "compression": "none" }, "templateId": "evmWalletFilter", "templateArgs": { "wallets": ["0xabc"] } }))) @@ -59,6 +59,8 @@ async fn create_webhook_evm_wallet() { "ethereum-mainnet", "--url", "https://hook.example/x", + "--compression", + "none", "--template", "evm-wallet", "--wallet", @@ -94,6 +96,8 @@ async fn create_webhook_evm_contract_events() { "ethereum-mainnet", "--url", "https://hook.example/y", + "--compression", + "none", "--template", "evm-contract-events", "--contract", @@ -182,6 +186,8 @@ async fn webhook_create_missing_wallets_errors() { "ethereum-mainnet", "--url", "https://hook.example", + "--compression", + "none", "--template", "evm-wallet", ], @@ -191,6 +197,147 @@ async fn webhook_create_missing_wallets_errors() { assert!(out.stderr.contains("--wallet"), "stderr={}", out.stderr); } +#[tokio::test] +async fn create_webhook_by_list() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/webhooks/rest/v1/webhooks/template/evmWalletFilter")) + .and(body_partial_json(json!({ + "destination_attributes": { "compression": "gzip" }, + "templateId": "evmWalletFilter", + "templateArgs": { "walletsListName": "my-list" } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(webhook_payload("wh-l"))) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "webhook", + "create", + "--name", + "w-list", + "--network", + "ethereum-mainnet", + "--url", + "https://hook.example/l", + "--compression", + "gzip", + "--template", + "evm-wallet", + "--wallets-list-name", + "my-list", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn create_webhook_evm_abi_by_list() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/webhooks/rest/v1/webhooks/template/evmAbiFilter")) + .and(body_partial_json(json!({ + "templateId": "evmAbiFilter", + "templateArgs": { "abiJson": "[]", "contractsListName": "my-contracts" } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(webhook_payload("wh-a"))) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "webhook", + "create", + "--name", + "w-abi", + "--network", + "ethereum-mainnet", + "--url", + "https://hook.example/a", + "--compression", + "none", + "--template", + "evm-abi", + "--abi", + "[]", + "--contracts-list-name", + "my-contracts", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn create_webhook_inline_and_list_conflict() { + let server = MockServer::start().await; + // Supplying both inline and list-name is a client-side error; nothing fires. + Mock::given(method("POST")) + .and(path("/webhooks/rest/v1/webhooks/template/evmWalletFilter")) + .respond_with(ResponseTemplate::new(200).set_body_json(webhook_payload("wh-x"))) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "webhook", + "create", + "--name", + "w1", + "--network", + "ethereum-mainnet", + "--url", + "https://hook.example/x", + "--compression", + "none", + "--template", + "evm-wallet", + "--wallet", + "0xabc", + "--wallets-list-name", + "my-list", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!(out.stderr.contains("not both"), "stderr={}", out.stderr); +} + +#[tokio::test] +async fn create_webhook_missing_compression_errors() { + let server = MockServer::start().await; + // --compression is required; the request never fires. + Mock::given(method("POST")) + .and(path("/webhooks/rest/v1/webhooks/template/evmWalletFilter")) + .respond_with(ResponseTemplate::new(200).set_body_json(webhook_payload("wh-x"))) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "webhook", + "create", + "--name", + "w1", + "--network", + "ethereum-mainnet", + "--url", + "https://hook.example/x", + "--template", + "evm-wallet", + "--wallet", + "0xabc", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "stderr={}", out.stderr); + assert!(out.stderr.contains("compression"), "stderr={}", out.stderr); +} + // ---- 400 error rendering ---- // /// A close-to-real NestJS validator 400 from the webhooks service. @@ -228,6 +375,8 @@ async fn create_webhook_400_renders_bullets_with_typo_suggestion() { "ethereum-mainnetsds", "--url", "https://hook.example/x", + "--compression", + "none", "--template", "evm-wallet", "--wallet", @@ -277,6 +426,8 @@ async fn create_webhook_400_far_typo_no_suggestion() { "sfjla", "--url", "https://hook.example/x", + "--compression", + "none", "--template", "evm-wallet", "--wallet",