From f87fe110975a3a41daa93ad6810e88a1ebbbe2ff Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 09:57:22 -0400 Subject: [PATCH 1/8] feat(endpoint): show security, rate limits, and multichain in 'endpoint show' table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table view of 'qn endpoint show' only rendered six summary fields, while --format json carried the full response. Add dotted-subfield rows for is_multichain, each security feature (enabled mark + configured count), the custom IP header, and every rate-limit bucket; the tags row now always renders (— when empty). Rows for security.*/rate_limits.* are omitted when the API returns no such object. Adds tests/table_snapshots.rs: insta snapshots of real-binary stdout against a wiremock server, covering the full and minimal payloads. --- src/commands/endpoint/render.rs | 81 +++++++++++++- ...e_snapshots__endpoint_show_full_table.snap | 25 +++++ ...le_omits_security_and_rate_limit_rows.snap | 13 +++ tests/table_snapshots.rs | 105 ++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/snapshots/table_snapshots__endpoint_show_full_table.snap create mode 100644 tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap create mode 100644 tests/table_snapshots.rs diff --git a/src/commands/endpoint/render.rs b/src/commands/endpoint/render.rs index e001e0c..6e28170 100644 --- a/src/commands/endpoint/render.rs +++ b/src/commands/endpoint/render.rs @@ -102,7 +102,9 @@ impl Render for SingleEndpointView { ]); t.add_row(vec![Cell::new("http_url"), Cell::new(&e.http_url)]); t.add_row(vec![Cell::new("wss_url"), opt_cell(&e.wss_url)]); - if !e.tags.is_empty() { + if e.tags.is_empty() { + t.add_row(vec![Cell::new("tags"), Cell::new("—")]); + } else { let tags = e .tags .iter() @@ -111,6 +113,83 @@ impl Render for SingleEndpointView { .join(", "); t.add_row(vec![Cell::new("tags"), Cell::new(tags)]); } + t.add_row(vec![ + Cell::new("is_multichain"), + bool_cell(Some(e.is_multichain)), + ]); + if let Some(sec) = &e.security { + let opts = sec.options.as_ref(); + let feature = |enabled: Option, count: usize| { + let mark = match enabled { + Some(true) => "✓", + Some(false) => "✗", + None => "—", + }; + Cell::new(format!("{mark} ({count})")) + }; + t.add_row(vec![ + Cell::new("security.tokens"), + feature( + opts.and_then(|o| o.tokens), + sec.tokens.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.jwts"), + feature( + opts.and_then(|o| o.jwts), + sec.jwts.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.domain_masks"), + feature( + opts.and_then(|o| o.domain_masks), + sec.domain_masks.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.ips"), + feature( + opts.and_then(|o| o.ips), + sec.ips.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.referrers"), + feature( + opts.and_then(|o| o.referrers), + sec.referrers.as_deref().unwrap_or(&[]).len(), + ), + ]); + t.add_row(vec![ + Cell::new("security.request_filters"), + feature( + opts.and_then(|o| o.request_filters), + sec.request_filters.as_deref().unwrap_or(&[]).len(), + ), + ]); + let ip_header = opts + .and_then(|o| o.ip_custom_header.as_ref()) + .and_then(|h| h.value.clone()); + t.add_row(vec![ + Cell::new("security.ip_custom_header"), + opt_cell(&ip_header), + ]); + } + if let Some(rl) = &e.rate_limits { + t.add_row(vec![ + Cell::new("rate_limits.by_ip"), + bool_cell(rl.rate_limit_by_ip), + ]); + t.add_row(vec![ + Cell::new("rate_limits.account"), + opt_cell(&rl.account), + ]); + t.add_row(vec![Cell::new("rate_limits.rps"), opt_cell(&rl.rps)]); + t.add_row(vec![Cell::new("rate_limits.rpm"), opt_cell(&rl.rpm)]); + t.add_row(vec![Cell::new("rate_limits.rpd"), opt_cell(&rl.rpd)]); + } write_table(w, &t) } } diff --git a/tests/snapshots/table_snapshots__endpoint_show_full_table.snap b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap new file mode 100644 index 0000000..5b60394 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap @@ -0,0 +1,25 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id ep-1 +label — +status active +chain/network eth/mainnet +http_url https://ep-1.example +wss_url wss://ep-1.example +tags — +is_multichain ✗ +security.tokens ✓ (1) +security.jwts ✗ (0) +security.domain_masks ✗ (0) +security.ips ✗ (0) +security.referrers ✗ (0) +security.request_filters ✗ (0) +security.ip_custom_header — +rate_limits.by_ip ✗ +rate_limits.account -1 +rate_limits.rps -1 +rate_limits.rpm -1 +rate_limits.rpd -1 diff --git a/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap b/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap new file mode 100644 index 0000000..bb12bed --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_show_minimal_table_omits_security_and_rate_limit_rows.snap @@ -0,0 +1,13 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id ep-1 +label — +status — +chain/network solana/mainnet +http_url https://ep-1.example +wss_url — +tags — +is_multichain ✗ diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs new file mode 100644 index 0000000..12934f1 --- /dev/null +++ b/tests/table_snapshots.rs @@ -0,0 +1,105 @@ +//! Snapshot tests for human-readable table output, rendered by the real +//! binary against a wiremock server. +//! +//! Unlike `output_snapshots.rs` (which pins layout via re-declared renderers), +//! these run `qn` as a subprocess so the snapshot covers the actual +//! decode-and-render path for each command. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Mount `body` at `GET url_path`, run `qn --format table ` against the +/// mock server, and return stdout. Panics (with stderr) on non-zero exit. +async fn table_stdout(url_path: &str, body: serde_json::Value, args: &[&str]) -> String { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(url_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + + let uri = server.uri(); + let mut argv = vec![ + "--api-key", + "test", + "--base-url", + uri.as_str(), + "--no-input", + "--format", + "table", + ]; + argv.extend(args); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args(&argv) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[tokio::test] +async fn endpoint_show_full_table() { + let body = serde_json::json!({ + "data": { + "id": "ep-1", + "label": null, + "status": "active", + "chain": "eth", + "network": "mainnet", + "http_url": "https://ep-1.example", + "wss_url": "wss://ep-1.example", + "security": { + "options": { + "tokens": true, + "jwts": false, + "domainMasks": false, + "ips": false, + "referrers": false, + "requestFilters": false, + "ipCustomHeader": { "value": null } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" } + ], + "jwts": null, + "referrers": null, + "domain_masks": null, + "ips": null, + "request_filters": null + }, + "rate_limits": { + "rate_limit_by_ip": false, + "account": -1, + "rps": -1, + "rpm": -1, + "rpd": -1 + }, + "tags": [], + "is_multichain": false + } + }); + let out = table_stdout("/v0/endpoints/ep-1", body, &["endpoint", "show", "ep-1"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { + let body = serde_json::json!({ + "data": { + "id": "ep-1", + "chain": "solana", + "network": "mainnet", + "http_url": "https://ep-1.example", + "tags": [] + } + }); + let out = table_stdout("/v0/endpoints/ep-1", body, &["endpoint", "show", "ep-1"]).await; + insta::assert_snapshot!(out); +} From 258fd3d9e9a96c7d9f1a7e3275a8a2fc28774fd7 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:00:05 -0400 Subject: [PATCH 2/8] feat(endpoint): show toggles and configured items in 'security show' table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'qn endpoint security show' table only listed per-feature item counts, hiding both the enabled/disabled toggles and the configured items themselves — and the security token/jwt/referrer subcommands have no list verbs, so the items had no table view at all. Render an OPTION/ENABLED table from the options toggles, then one section per non-empty item list (TOKENS, JWTS, REFERRERS, DOMAIN_MASKS, IPS, REQUEST_FILTERS) with the full item rows. Empty lists render no section, so the common single-feature config stays compact. Snapshot tests cover an all-features payload and a single-token one. --- src/commands/endpoint/security.rs | 146 +++++++++++------- ...__endpoint_security_show_all_sections.snap | 37 +++++ ...how_single_token_omits_empty_sections.snap | 16 ++ tests/table_snapshots.rs | 77 +++++++++ 4 files changed, 224 insertions(+), 52 deletions(-) create mode 100644 tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap create mode 100644 tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index 1a6e81f..c7bd941 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -14,7 +14,7 @@ use serde::Serialize; use crate::confirm::confirm_mild; use crate::context::Ctx; use crate::errors::CliError; -use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render}; +use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render}; use crate::retry::retrying; #[derive(Debug, Subcommand)] @@ -458,72 +458,114 @@ impl Render for SecurityShowView { return Ok(()); } }; + let opts = data.options.as_ref(); let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["FEATURE", "COUNT", "DETAIL"]); - let tokens = data.tokens.as_deref().unwrap_or(&[]); + set_header_bold(&mut t, ctx, vec!["OPTION", "ENABLED"]); t.add_row(vec![ Cell::new("tokens"), - Cell::new(tokens.len()), - Cell::new(""), - ]); - let referrers = data.referrers.as_deref().unwrap_or(&[]); - t.add_row(vec![ - Cell::new("referrers"), - Cell::new(referrers.len()), - Cell::new( - referrers - .iter() - .filter_map(|r| r.referrer.clone()) - .collect::>() - .join(", "), - ), - ]); - let ips = data.ips.as_deref().unwrap_or(&[]); - t.add_row(vec![ - Cell::new("ips"), - Cell::new(ips.len()), - Cell::new( - ips.iter() - .map(|i| i.ip.clone()) - .collect::>() - .join(", "), - ), + bool_cell(opts.and_then(|o| o.tokens)), ]); - let jwts = data.jwts.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("jwts"), - Cell::new(jwts.len()), - Cell::new(""), + bool_cell(opts.and_then(|o| o.jwts)), ]); - let masks = data.domain_masks.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("domain_masks"), - Cell::new(masks.len()), - Cell::new( - masks - .iter() - .map(|d| d.domain.clone()) - .collect::>() - .join(", "), - ), + bool_cell(opts.and_then(|o| o.domain_masks)), + ]); + t.add_row(vec![Cell::new("ips"), bool_cell(opts.and_then(|o| o.ips))]); + t.add_row(vec![ + Cell::new("referrers"), + bool_cell(opts.and_then(|o| o.referrers)), ]); - let filters = data.request_filters.as_deref().unwrap_or(&[]); t.add_row(vec![ Cell::new("request_filters"), - Cell::new(filters.len()), - Cell::new(""), + bool_cell(opts.and_then(|o| o.request_filters)), ]); - let ip_header = data - .options - .as_ref() + let ip_header = opts .and_then(|o| o.ip_custom_header.as_ref()) .and_then(|h| h.value.clone()); - t.add_row(vec![ - Cell::new("ip_custom_header"), - opt_cell(&ip_header), - Cell::new(""), - ]); - write_table(w, &t) + t.add_row(vec![Cell::new("ip_custom_header"), opt_cell(&ip_header)]); + write_table(w, &t)?; + + // One section per configured item list; lists with no items render + // no section so the common single-feature case stays compact. + let section = |w: &mut dyn std::io::Write, + title: &str, + headers: Vec<&str>, + rows: Vec>| + -> std::io::Result<()> { + if rows.is_empty() { + return Ok(()); + } + writeln!(w)?; + writeln!(w, "{} ({})", title, rows.len())?; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, headers); + for row in rows { + t.add_row(row); + } + write_table(w, &t) + }; + + let tokens = data.tokens.as_deref().unwrap_or(&[]); + section( + w, + "TOKENS", + vec!["ID", "TOKEN"], + tokens + .iter() + .map(|t| vec![Cell::new(&t.id), Cell::new(&t.token)]) + .collect(), + )?; + let jwts = data.jwts.as_deref().unwrap_or(&[]); + section( + w, + "JWTS", + vec!["ID", "NAME", "KID"], + jwts.iter() + .map(|j| vec![Cell::new(&j.id), Cell::new(&j.name), Cell::new(&j.kid)]) + .collect(), + )?; + let referrers = data.referrers.as_deref().unwrap_or(&[]); + section( + w, + "REFERRERS", + vec!["ID", "REFERRER"], + referrers + .iter() + .map(|r| vec![Cell::new(&r.id), opt_cell(&r.referrer)]) + .collect(), + )?; + let masks = data.domain_masks.as_deref().unwrap_or(&[]); + section( + w, + "DOMAIN_MASKS", + vec!["ID", "DOMAIN"], + masks + .iter() + .map(|d| vec![Cell::new(&d.id), Cell::new(&d.domain)]) + .collect(), + )?; + let ips = data.ips.as_deref().unwrap_or(&[]); + section( + w, + "IPS", + vec!["ID", "IP"], + ips.iter() + .map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)]) + .collect(), + )?; + let filters = data.request_filters.as_deref().unwrap_or(&[]); + section( + w, + "REQUEST_FILTERS", + vec!["ID", "METHODS"], + filters + .iter() + .map(|f| vec![Cell::new(&f.id), Cell::new(f.method.join(", "))]) + .collect(), + ) } } diff --git a/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap b/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap new file mode 100644 index 0000000..96fb526 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_security_show_all_sections.snap @@ -0,0 +1,37 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +OPTION ENABLED +tokens ✓ +jwts ✓ +domain_masks ✓ +ips ✓ +referrers ✓ +request_filters ✓ +ip_custom_header x-real-ip + +TOKENS (2) +ID TOKEN +tok-1 0xabc +tok-2 0xdef + +JWTS (1) +ID NAME KID +jwt-1 ci kid-1 + +REFERRERS (1) +ID REFERRER +ref-1 https://app.example.com + +DOMAIN_MASKS (1) +ID DOMAIN +dm-1 *.example.com + +IPS (1) +ID IP +ip-1 203.0.113.7 + +REQUEST_FILTERS (1) +ID METHODS +rf-1 eth_blockNumber, eth_call diff --git a/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap b/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap new file mode 100644 index 0000000..fd0fbc0 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_security_show_single_token_omits_empty_sections.snap @@ -0,0 +1,16 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +OPTION ENABLED +tokens ✓ +jwts ✗ +domain_masks ✗ +ips ✗ +referrers ✗ +request_filters ✗ +ip_custom_header — + +TOKENS (1) +ID TOKEN +tok-1 0xabc diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 12934f1..bebbf14 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -89,6 +89,83 @@ async fn endpoint_show_full_table() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn endpoint_security_show_all_sections() { + let body = serde_json::json!({ + "data": { + "options": { + "tokens": true, + "jwts": true, + "domainMasks": true, + "ips": true, + "referrers": true, + "requestFilters": true, + "ipCustomHeader": { "value": "x-real-ip" } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" }, + { "id": "tok-2", "token": "0xdef" } + ], + "jwts": [ + { "id": "jwt-1", "public_key": "pk", "kid": "kid-1", "name": "ci" } + ], + "referrers": [ + { "id": "ref-1", "referrer": "https://app.example.com" } + ], + "domain_masks": [ + { "id": "dm-1", "domain": "*.example.com" } + ], + "ips": [ + { "id": "ip-1", "ip": "203.0.113.7" } + ], + "request_filters": [ + { "id": "rf-1", "method": ["eth_blockNumber", "eth_call"] } + ] + }, + "error": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/security", + body, + &["endpoint", "security", "show", "ep-1"], + ) + .await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn endpoint_security_show_single_token_omits_empty_sections() { + let body = serde_json::json!({ + "data": { + "options": { + "tokens": true, + "jwts": false, + "domainMasks": false, + "ips": false, + "referrers": false, + "requestFilters": false, + "ipCustomHeader": { "value": null } + }, + "tokens": [ + { "id": "tok-1", "token": "0xabc" } + ], + "jwts": null, + "referrers": null, + "domain_masks": null, + "ips": null, + "request_filters": null + }, + "error": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/security", + body, + &["endpoint", "security", "show", "ep-1"], + ) + .await; + insta::assert_snapshot!(out); +} + #[tokio::test] async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let body = serde_json::json!({ From 893cce9d3cfa7c2c3d3e0b3f5a5c6dc182b67529 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:01:55 -0400 Subject: [PATCH 3/8] feat(endpoint): add ERROR column to 'endpoint logs' table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request can return HTTP 200 while failing at the JSON-RPC layer (e.g. error_code -32601 for an unknown method). The table previously showed only the HTTP status, so such failures looked successful; the error_code field was visible only in --format json. Render it as an ERROR column after STATUS, — when absent. --- src/commands/endpoint/render.rs | 3 +- ...dpoint_logs_table_includes_error_code.snap | 7 ++++ tests/table_snapshots.rs | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap diff --git a/src/commands/endpoint/render.rs b/src/commands/endpoint/render.rs index 6e28170..7c8b6da 100644 --- a/src/commands/endpoint/render.rs +++ b/src/commands/endpoint/render.rs @@ -239,13 +239,14 @@ impl Render for EndpointLogsView { set_header_bold( &mut t, ctx, - vec!["TIME", "METHOD", "STATUS", "NETWORK", "REQUEST_ID"], + vec!["TIME", "METHOD", "STATUS", "ERROR", "NETWORK", "REQUEST_ID"], ); for l in &self.0.data { t.add_row(vec![ Cell::new(&l.timestamp), opt_cell(&l.method), opt_cell(&l.status), + opt_cell(&l.error_code), opt_cell(&l.network), opt_cell(&l.request_id), ]); diff --git a/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap b/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap new file mode 100644 index 0000000..062f797 --- /dev/null +++ b/tests/snapshots/table_snapshots__endpoint_logs_table_includes_error_code.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +TIME METHOD STATUS ERROR NETWORK REQUEST_ID +2026-01-01T00:00:00.000Z eth_blockNumbre 200 -32601 mainnet req-1 +2026-01-01T00:00:01.000Z eth_blockNumber 200 — mainnet req-2 diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index bebbf14..23ec220 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -166,6 +166,44 @@ async fn endpoint_security_show_single_token_omits_empty_sections() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn endpoint_logs_table_includes_error_code() { + let body = serde_json::json!({ + "data": [ + { + "timestamp": "2026-01-01T00:00:00.000Z", + "method": "eth_blockNumbre", + "network": "mainnet", + "http_method": "POST", + "status": 200, + "error_code": -32601, + "url": "/", + "request_id": "req-1", + "details": null + }, + { + "timestamp": "2026-01-01T00:00:01.000Z", + "method": "eth_blockNumber", + "network": "mainnet", + "http_method": "POST", + "status": 200, + "error_code": null, + "url": "/", + "request_id": "req-2", + "details": null + } + ], + "next_at": null + }); + let out = table_stdout( + "/v0/endpoints/ep-1/logs", + body, + &["endpoint", "logs", "ep-1", "--from", "2026-01-01T00:00:00Z"], + ) + .await; + insta::assert_snapshot!(out); +} + #[tokio::test] async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let body = serde_json::json!({ From 90f07b6c14d4ff04b7549b02cf21966cd5e8b97e Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:03:20 -0400 Subject: [PATCH 4/8] feat(team): show members and pending invites in 'team show' table The table view rendered only id, name, and default_role; the member list and pending invites were visible only in --format json. Add a members_count row plus MEMBERS and PENDING_INVITES sections (EMAIL/NAME/ROLE/STATUS), omitted when empty. --- src/commands/team.rs | 30 +++++++++- ...how_lists_members_and_pending_invites.snap | 18 ++++++ ...w_without_members_renders_fields_only.snap | 9 +++ tests/table_snapshots.rs | 55 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap create mode 100644 tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap diff --git a/src/commands/team.rs b/src/commands/team.rs index b9adc99..6526708 100644 --- a/src/commands/team.rs +++ b/src/commands/team.rs @@ -266,7 +266,35 @@ impl Render for TeamDetailView { Cell::new("default_role"), opt_cell(&detail.default_role), ]); - write_table(w, &t) + t.add_row(vec![ + Cell::new("members_count"), + opt_cell(&detail.members_count), + ]); + write_table(w, &t)?; + + let member_section = |w: &mut dyn std::io::Write, + title: &str, + users: &[quicknode_sdk::admin::TeamUser]| + -> std::io::Result<()> { + if users.is_empty() { + return Ok(()); + } + writeln!(w)?; + writeln!(w, "{} ({})", title, users.len())?; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["EMAIL", "NAME", "ROLE", "STATUS"]); + for u in users { + t.add_row(vec![ + Cell::new(&u.email), + opt_cell(&u.full_name), + opt_cell(&u.role), + opt_cell(&u.status), + ]); + } + write_table(w, &t) + }; + member_section(w, "MEMBERS", &detail.users)?; + member_section(w, "PENDING_INVITES", &detail.pending_invites) } } diff --git a/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap new file mode 100644 index 0000000..272880d --- /dev/null +++ b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap @@ -0,0 +1,18 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id 7 +name core +default_role viewer +members_count 2 + +MEMBERS (2) +EMAIL NAME ROLE STATUS +alice@example.com Alice Example admin active +bob@example.com — viewer active + +PENDING_INVITES (1) +EMAIL NAME ROLE STATUS +carol@example.com — viewer pending diff --git a/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap b/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap new file mode 100644 index 0000000..c4c9560 --- /dev/null +++ b/tests/snapshots/table_snapshots__team_show_without_members_renders_fields_only.snap @@ -0,0 +1,9 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +id 7 +name core +default_role — +members_count — diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 23ec220..b03eafa 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -204,6 +204,61 @@ async fn endpoint_logs_table_includes_error_code() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn team_show_lists_members_and_pending_invites() { + let body = serde_json::json!({ + "data": { + "id": 7, + "name": "core", + "default_role": "viewer", + "members_count": 2, + "users": [ + { + "id": 1, + "full_name": "Alice Example", + "email": "alice@example.com", + "role": "admin", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "photo_url": null + }, + { + "id": 2, + "full_name": null, + "email": "bob@example.com", + "role": "viewer", + "status": "active", + "created_at": null, + "photo_url": null + } + ], + "pending_invites": [ + { + "id": 3, + "full_name": null, + "email": "carol@example.com", + "role": "viewer", + "status": "pending", + "created_at": null, + "photo_url": null + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/teams/7", body, &["team", "show", "7"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn team_show_without_members_renders_fields_only() { + let body = serde_json::json!({ + "data": { "id": 7, "name": "core", "default_role": null } + }); + let out = table_stdout("/v0/teams/7", body, &["team", "show", "7"]).await; + insta::assert_snapshot!(out); +} + #[tokio::test] async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let body = serde_json::json!({ From 5a1a095de51e3e70ffe775455bda226db7f697bd Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:04:41 -0400 Subject: [PATCH 5/8] feat(billing): add STATUS and MARKETPLACE columns to payments table Without the status column, failed or pending payments were indistinguishable from successful ones outside --format json. Also surface the marketplace portion of each payment. --- src/commands/billing.rs | 15 +++++++++- ...table_includes_status_and_marketplace.snap | 7 +++++ tests/table_snapshots.rs | 29 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap diff --git a/src/commands/billing.rs b/src/commands/billing.rs index a47c88c..71eb52d 100644 --- a/src/commands/billing.rs +++ b/src/commands/billing.rs @@ -100,13 +100,26 @@ impl Render for PaymentsView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["CREATED", "AMOUNT", "CURRENCY", "CARD"]); + set_header_bold( + &mut t, + ctx, + vec![ + "CREATED", + "AMOUNT", + "CURRENCY", + "STATUS", + "CARD", + "MARKETPLACE", + ], + ); for p in &data.payments { t.add_row(vec![ Cell::new(&p.created_at), Cell::new(&p.amount), Cell::new(&p.currency), + Cell::new(&p.status), opt_cell(&p.card_last_4), + opt_cell(&p.marketplace_amount), ]); } write_table(w, &t) diff --git a/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap b/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap new file mode 100644 index 0000000..cea574b --- /dev/null +++ b/tests/snapshots/table_snapshots__billing_payments_table_includes_status_and_marketplace.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +CREATED AMOUNT CURRENCY STATUS CARD MARKETPLACE +2026-01-01T00:00:00Z 49.00 usd succeeded 4242 9.00 +2026-02-01T00:00:00Z 49.00 usd failed — — diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index b03eafa..0e9e0ac 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -259,6 +259,35 @@ async fn team_show_without_members_renders_fields_only() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn billing_payments_table_includes_status_and_marketplace() { + let body = serde_json::json!({ + "data": { + "payments": [ + { + "amount": "49.00", + "card_last_4": "4242", + "created_at": "2026-01-01T00:00:00Z", + "currency": "usd", + "status": "succeeded", + "marketplace_amount": "9.00" + }, + { + "amount": "49.00", + "card_last_4": null, + "created_at": "2026-02-01T00:00:00Z", + "currency": "usd", + "status": "failed", + "marketplace_amount": null + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/billing/payments", body, &["billing", "payments"]).await; + insta::assert_snapshot!(out); +} + #[tokio::test] async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let body = serde_json::json!({ From 35bef0fdfd410d1b015ac7412f65ec3032f0f650 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:06:40 -0400 Subject: [PATCH 6/8] feat(usage): surface overages, time window, chain/network, and request counts - 'usage summary' gains overages plus start_time/end_time rows so the numbers carry their window; unix seconds render as RFC-3339 via a new ParsedTime::from_unix, matching what --from/--to accept. - 'usage by-method' gains CHAIN and NETWORK columns (a method name alone is ambiguous on multi-chain accounts) and renders ARCHIVE with the standard boolean cell. - 'usage by-tag' gains the REQUESTS count. --- src/commands/usage.rs | 25 ++++++-- src/time_arg.rs | 5 ++ ...thod_table_includes_chain_and_network.snap | 7 +++ ..._usage_by_tag_table_includes_requests.snap | 7 +++ ...mmary_table_shows_overages_and_window.snap | 11 ++++ tests/table_snapshots.rs | 59 +++++++++++++++++++ 6 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap create mode 100644 tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap create mode 100644 tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap diff --git a/src/commands/usage.rs b/src/commands/usage.rs index c46a482..3a6a020 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -7,7 +7,7 @@ use serde::Serialize; use crate::context::Ctx; use crate::errors::CliError; -use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render}; +use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render}; use crate::retry::retrying; use crate::time_arg; @@ -128,6 +128,16 @@ impl Render for UsageSummaryView { opt_cell(&data.credits_remaining), ]); t.add_row(vec![Cell::new("limit"), opt_cell(&data.limit)]); + t.add_row(vec![Cell::new("overages"), opt_cell(&data.overages)]); + // The API reports the window as unix seconds; show RFC-3339 like the + // --from/--to flags accept. Out-of-range values fall back to the raw + // number rather than erroring a read-only command. + let time_cell = |ts: i64| match time_arg::ParsedTime::from_unix(ts) { + Some(t) => Cell::new(t.to_rfc3339()), + None => Cell::new(ts), + }; + t.add_row(vec![Cell::new("start_time"), time_cell(data.start_time)]); + t.add_row(vec![Cell::new("end_time"), time_cell(data.end_time)]); write_table(w, &t) } } @@ -179,12 +189,18 @@ impl Render for UsageByMethodView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["METHOD", "CREDITS", "ARCHIVE"]); + set_header_bold( + &mut t, + ctx, + vec!["METHOD", "CHAIN", "NETWORK", "CREDITS", "ARCHIVE"], + ); for m in &data.methods { t.add_row(vec![ Cell::new(&m.method_name), + opt_cell(&m.chain), + opt_cell(&m.network), Cell::new(m.credits_used), - opt_cell(&m.archive), + bool_cell(m.archive), ]); } write_table(w, &t) @@ -233,12 +249,13 @@ impl Render for UsageByTagView { } }; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["TAG_ID", "LABEL", "CREDITS"]); + set_header_bold(&mut t, ctx, vec!["TAG_ID", "LABEL", "CREDITS", "REQUESTS"]); for tg in &data.tags { t.add_row(vec![ opt_cell(&tg.tag_id), Cell::new(&tg.label), Cell::new(tg.credits_used), + Cell::new(tg.requests), ]); } write_table(w, &t) diff --git a/src/time_arg.rs b/src/time_arg.rs index 33c9558..e8f8985 100644 --- a/src/time_arg.rs +++ b/src/time_arg.rs @@ -28,6 +28,11 @@ impl ParsedTime { self.0.unix_timestamp() } + /// Builds from unix seconds; `None` if out of `OffsetDateTime` range. + pub fn from_unix(ts: i64) -> Option { + OffsetDateTime::from_unix_timestamp(ts).ok().map(Self) + } + pub fn to_rfc3339(self) -> String { self.0 .format(&Rfc3339) diff --git a/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap b/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap new file mode 100644 index 0000000..d674cfa --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_by_method_table_includes_chain_and_network.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +METHOD CHAIN NETWORK CREDITS ARCHIVE +eth_call eth mainnet 900 ✗ +getBlockHeight solana mainnet 300 — diff --git a/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap b/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap new file mode 100644 index 0000000..bb30952 --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_by_tag_table_includes_requests.snap @@ -0,0 +1,7 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +TAG_ID LABEL CREDITS REQUESTS +1 prod 1000 420 +— untagged 200 80 diff --git a/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap b/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap new file mode 100644 index 0000000..7868fd7 --- /dev/null +++ b/tests/snapshots/table_snapshots__usage_summary_table_shows_overages_and_window.snap @@ -0,0 +1,11 @@ +--- +source: tests/table_snapshots.rs +expression: out +--- +FIELD VALUE +credits_used 1200 +credits_remaining 8800 +limit 10000 +overages 0 +start_time 2026-01-01T00:00:00Z +end_time 2026-02-01T00:00:00Z diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 0e9e0ac..d29e2aa 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -288,6 +288,65 @@ async fn billing_payments_table_includes_status_and_marketplace() { insta::assert_snapshot!(out); } +#[tokio::test] +async fn usage_summary_table_shows_overages_and_window() { + let body = serde_json::json!({ + "data": { + "credits_used": 1200, + "credits_remaining": 8800, + "limit": 10000, + "overages": 0, + "start_time": 1767225600, + "end_time": 1769904000 + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc", body, &["usage", "summary"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn usage_by_method_table_includes_chain_and_network() { + let body = serde_json::json!({ + "data": { + "methods": [ + { + "method_name": "eth_call", + "credits_used": 900, + "archive": false, + "network": "mainnet", + "chain": "eth" + }, + { + "method_name": "getBlockHeight", + "credits_used": 300, + "archive": null, + "network": "mainnet", + "chain": "solana" + } + ] + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc/by-method", body, &["usage", "by-method"]).await; + insta::assert_snapshot!(out); +} + +#[tokio::test] +async fn usage_by_tag_table_includes_requests() { + let body = serde_json::json!({ + "data": { + "tags": [ + { "tag_id": 1, "label": "prod", "credits_used": 1000, "requests": 420 }, + { "tag_id": null, "label": "untagged", "credits_used": 200, "requests": 80 } + ] + }, + "error": null + }); + let out = table_stdout("/v0/usage/rpc/by-tag", body, &["usage", "by-tag"]).await; + insta::assert_snapshot!(out); +} + #[tokio::test] async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { let body = serde_json::json!({ From 4929fedce22961f54497316a2836c2847c4acb68 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:17:58 -0400 Subject: [PATCH 7/8] feat(team): add ID column to 'team show' member tables The member-management commands take a user id, so surface it as the first column of the MEMBERS and PENDING_INVITES sections instead of leaving it JSON-only. --- src/commands/team.rs | 3 ++- ...s__team_show_lists_members_and_pending_invites.snap | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/team.rs b/src/commands/team.rs index 6526708..5196268 100644 --- a/src/commands/team.rs +++ b/src/commands/team.rs @@ -282,9 +282,10 @@ impl Render for TeamDetailView { writeln!(w)?; writeln!(w, "{} ({})", title, users.len())?; let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["EMAIL", "NAME", "ROLE", "STATUS"]); + set_header_bold(&mut t, ctx, vec!["ID", "EMAIL", "NAME", "ROLE", "STATUS"]); for u in users { t.add_row(vec![ + Cell::new(u.id), Cell::new(&u.email), opt_cell(&u.full_name), opt_cell(&u.role), diff --git a/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap index 272880d..e0571c0 100644 --- a/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap +++ b/tests/snapshots/table_snapshots__team_show_lists_members_and_pending_invites.snap @@ -9,10 +9,10 @@ default_role viewer members_count 2 MEMBERS (2) -EMAIL NAME ROLE STATUS -alice@example.com Alice Example admin active -bob@example.com — viewer active +ID EMAIL NAME ROLE STATUS +1 alice@example.com Alice Example admin active +2 bob@example.com — viewer active PENDING_INVITES (1) -EMAIL NAME ROLE STATUS -carol@example.com — viewer pending +ID EMAIL NAME ROLE STATUS +3 carol@example.com — viewer pending From e4c367a7b41a3e7270b088f0aace4eaf25d5e610 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 10:21:12 -0400 Subject: [PATCH 8/8] feat(endpoint): list configured security items in 'endpoint show' The dotted security.* rows only carry an enabled mark and a count; the items themselves (token values, filter methods, referrer URLs, item ids needed by the remove commands) required either --format json or a separate 'security show' call. Extract that command's item-section renderer into a shared security_item_sections helper and append the same TOKENS/JWTS/REFERRERS/DOMAIN_MASKS/IPS/REQUEST_FILTERS sections to 'endpoint show'. Empty lists still render no section. --- src/commands/endpoint/render.rs | 6 +- src/commands/endpoint/security.rs | 165 +++++++++--------- ...e_snapshots__endpoint_show_full_table.snap | 14 +- tests/table_snapshots.rs | 8 +- 4 files changed, 108 insertions(+), 85 deletions(-) diff --git a/src/commands/endpoint/render.rs b/src/commands/endpoint/render.rs index 7c8b6da..78723b4 100644 --- a/src/commands/endpoint/render.rs +++ b/src/commands/endpoint/render.rs @@ -190,7 +190,11 @@ impl Render for SingleEndpointView { t.add_row(vec![Cell::new("rate_limits.rpm"), opt_cell(&rl.rpm)]); t.add_row(vec![Cell::new("rate_limits.rpd"), opt_cell(&rl.rpd)]); } - write_table(w, &t) + write_table(w, &t)?; + if let Some(sec) = &e.security { + super::security::security_item_sections(w, ctx, sec)?; + } + Ok(()) } } diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index c7bd941..bcbea0f 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -487,86 +487,95 @@ impl Render for SecurityShowView { .and_then(|h| h.value.clone()); t.add_row(vec![Cell::new("ip_custom_header"), opt_cell(&ip_header)]); write_table(w, &t)?; + security_item_sections(w, ctx, data) + } +} - // One section per configured item list; lists with no items render - // no section so the common single-feature case stays compact. - let section = |w: &mut dyn std::io::Write, - title: &str, - headers: Vec<&str>, - rows: Vec>| - -> std::io::Result<()> { - if rows.is_empty() { - return Ok(()); - } - writeln!(w)?; - writeln!(w, "{} ({})", title, rows.len())?; - let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, headers); - for row in rows { - t.add_row(row); - } - write_table(w, &t) - }; +/// Renders one titled table per configured security item list (TOKENS, JWTS, +/// ...). Lists with no items render no section so lightly-configured +/// endpoints stay compact. Shared between `endpoint security show` and +/// `endpoint show`. +pub(crate) fn security_item_sections( + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + data: &quicknode_sdk::admin::EndpointSecurity, +) -> std::io::Result<()> { + let section = |w: &mut dyn std::io::Write, + title: &str, + headers: Vec<&str>, + rows: Vec>| + -> std::io::Result<()> { + if rows.is_empty() { + return Ok(()); + } + writeln!(w)?; + writeln!(w, "{} ({})", title, rows.len())?; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, headers); + for row in rows { + t.add_row(row); + } + write_table(w, &t) + }; - let tokens = data.tokens.as_deref().unwrap_or(&[]); - section( - w, - "TOKENS", - vec!["ID", "TOKEN"], - tokens - .iter() - .map(|t| vec![Cell::new(&t.id), Cell::new(&t.token)]) - .collect(), - )?; - let jwts = data.jwts.as_deref().unwrap_or(&[]); - section( - w, - "JWTS", - vec!["ID", "NAME", "KID"], - jwts.iter() - .map(|j| vec![Cell::new(&j.id), Cell::new(&j.name), Cell::new(&j.kid)]) - .collect(), - )?; - let referrers = data.referrers.as_deref().unwrap_or(&[]); - section( - w, - "REFERRERS", - vec!["ID", "REFERRER"], - referrers - .iter() - .map(|r| vec![Cell::new(&r.id), opt_cell(&r.referrer)]) - .collect(), - )?; - let masks = data.domain_masks.as_deref().unwrap_or(&[]); - section( - w, - "DOMAIN_MASKS", - vec!["ID", "DOMAIN"], - masks - .iter() - .map(|d| vec![Cell::new(&d.id), Cell::new(&d.domain)]) - .collect(), - )?; - let ips = data.ips.as_deref().unwrap_or(&[]); - section( - w, - "IPS", - vec!["ID", "IP"], - ips.iter() - .map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)]) - .collect(), - )?; - let filters = data.request_filters.as_deref().unwrap_or(&[]); - section( - w, - "REQUEST_FILTERS", - vec!["ID", "METHODS"], - filters - .iter() - .map(|f| vec![Cell::new(&f.id), Cell::new(f.method.join(", "))]) - .collect(), - ) - } + let tokens = data.tokens.as_deref().unwrap_or(&[]); + section( + w, + "TOKENS", + vec!["ID", "TOKEN"], + tokens + .iter() + .map(|t| vec![Cell::new(&t.id), Cell::new(&t.token)]) + .collect(), + )?; + let jwts = data.jwts.as_deref().unwrap_or(&[]); + section( + w, + "JWTS", + vec!["ID", "NAME", "KID"], + jwts.iter() + .map(|j| vec![Cell::new(&j.id), Cell::new(&j.name), Cell::new(&j.kid)]) + .collect(), + )?; + let referrers = data.referrers.as_deref().unwrap_or(&[]); + section( + w, + "REFERRERS", + vec!["ID", "REFERRER"], + referrers + .iter() + .map(|r| vec![Cell::new(&r.id), opt_cell(&r.referrer)]) + .collect(), + )?; + let masks = data.domain_masks.as_deref().unwrap_or(&[]); + section( + w, + "DOMAIN_MASKS", + vec!["ID", "DOMAIN"], + masks + .iter() + .map(|d| vec![Cell::new(&d.id), Cell::new(&d.domain)]) + .collect(), + )?; + let ips = data.ips.as_deref().unwrap_or(&[]); + section( + w, + "IPS", + vec!["ID", "IP"], + ips.iter() + .map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)]) + .collect(), + )?; + let filters = data.request_filters.as_deref().unwrap_or(&[]); + section( + w, + "REQUEST_FILTERS", + vec!["ID", "METHODS"], + filters + .iter() + .map(|f| vec![Cell::new(&f.id), Cell::new(f.method.join(", "))]) + .collect(), + ) } #[derive(Serialize)] diff --git a/tests/snapshots/table_snapshots__endpoint_show_full_table.snap b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap index 5b60394..1a0bd2b 100644 --- a/tests/snapshots/table_snapshots__endpoint_show_full_table.snap +++ b/tests/snapshots/table_snapshots__endpoint_show_full_table.snap @@ -16,10 +16,18 @@ security.jwts ✗ (0) security.domain_masks ✗ (0) security.ips ✗ (0) security.referrers ✗ (0) -security.request_filters ✗ (0) -security.ip_custom_header — +security.request_filters ✓ (1) +security.ip_custom_header x-real-ip rate_limits.by_ip ✗ rate_limits.account -1 rate_limits.rps -1 rate_limits.rpm -1 -rate_limits.rpd -1 +rate_limits.rpd -1 + +TOKENS (1) +ID TOKEN +tok-1 0xabc + +REQUEST_FILTERS (1) +ID METHODS +rf-1 eth_blockNumber, eth_call diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index d29e2aa..97d8bcd 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -62,8 +62,8 @@ async fn endpoint_show_full_table() { "domainMasks": false, "ips": false, "referrers": false, - "requestFilters": false, - "ipCustomHeader": { "value": null } + "requestFilters": true, + "ipCustomHeader": { "value": "x-real-ip" } }, "tokens": [ { "id": "tok-1", "token": "0xabc" } @@ -72,7 +72,9 @@ async fn endpoint_show_full_table() { "referrers": null, "domain_masks": null, "ips": null, - "request_filters": null + "request_filters": [ + { "id": "rf-1", "method": ["eth_blockNumber", "eth_call"] } + ] }, "rate_limits": { "rate_limit_by_ip": false,