diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index 44498c6..74b0b1b 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -308,11 +308,37 @@ async fn set_options(a: SetOptionsArgs, ctx: Ctx) -> Result<(), CliError> { crate::output::emit(&ctx.out, &SecurityOptionsListView(resp.data)) } +/// Best-effort post-add check: if the security option governing the item just +/// added is disabled, the item is not enforced and won't appear in list output, +/// so warn on stderr with the exact enable command. Never fails the command — +/// any error fetching the options is swallowed (the add already succeeded), +/// and the lookup is skipped entirely under `--quiet`. +async fn warn_if_option_disabled(ctx: &Ctx, id: &str, option: &str, flag: &str, item_desc: &str) { + if ctx.out.quiet { + return; + } + let Ok(resp) = ctx.sdk.admin.get_security_options(id).await else { + return; + }; + if resp + .data + .iter() + .any(|o| o.option == option && o.status == "disabled") + { + ctx.out.warn(&format!( + "⚠ The '{option}' security option is disabled on {id} —\n \ + {item_desc} will have no effect until you enable it:\n \ + qn endpoint security set-options --{flag} enabled {id}" + )); + } +} + async fn token(cmd: TokenCmd, ctx: Ctx) -> Result<(), CliError> { match cmd { TokenCmd::Create { id } => { ctx.sdk.admin.create_token(&id).await?; ctx.out.note(&format!("✓ Created token on {id}")); + warn_if_option_disabled(&ctx, &id, "tokens", "tokens", "this token").await; } TokenCmd::Delete { id, token_id } => { confirm_mild( @@ -337,6 +363,7 @@ async fn referrer(cmd: ReferrerCmd, ctx: Ctx) -> Result<(), CliError> { ctx.sdk.admin.create_referrer(&id, &req).await?; ctx.out .note(&format!("✓ Whitelisted referrer {referrer:?} on {id}")); + warn_if_option_disabled(&ctx, &id, "referrers", "referrers", "this referrer").await; } ReferrerCmd::Remove { id, referrer_id } => { confirm_mild( @@ -359,6 +386,7 @@ async fn ip(cmd: IpCmd, ctx: Ctx) -> Result<(), CliError> { }; ctx.sdk.admin.create_ip(&id, &req).await?; ctx.out.note(&format!("✓ Whitelisted IP {ip} on {id}")); + warn_if_option_disabled(&ctx, &id, "ips", "ips", "this IP").await; } IpCmd::Remove { id, ip_id } => { confirm_mild( @@ -396,6 +424,7 @@ async fn jwt(cmd: JwtCmd, ctx: Ctx) -> Result<(), CliError> { }; ctx.sdk.admin.create_jwt(&a.id, &req).await?; ctx.out.note(&format!("✓ Added JWT on {}", a.id)); + warn_if_option_disabled(&ctx, &a.id, "jwts", "jwts", "this JWT").await; } JwtCmd::Remove { id, jwt_id } => { confirm_mild(&ctx, &format!("Remove JWT {jwt_id} from endpoint {id}?"))?; @@ -415,6 +444,8 @@ async fn domain_mask(cmd: DomainMaskCmd, ctx: Ctx) -> Result<(), CliError> { ctx.sdk.admin.create_domain_mask(&id, &req).await?; ctx.out .note(&format!("✓ Added domain mask {domain:?} on {id}")); + warn_if_option_disabled(&ctx, &id, "domainMasks", "domain-masks", "this domain mask") + .await; } DomainMaskCmd::Remove { id, domain_mask_id } => { confirm_mild( @@ -449,6 +480,14 @@ async fn request_filter(cmd: RequestFilterCmd, ctx: Ctx) -> Result<(), CliError> })?; ctx.out .note(&format!("✓ Created request filter {} on {}", d.id, a.id)); + warn_if_option_disabled( + &ctx, + &a.id, + "requestFilters", + "request-filters", + "this request filter", + ) + .await; } RequestFilterCmd::Update(a) => { let mut methods = a.methods; @@ -497,6 +536,14 @@ async fn ip_header(cmd: IpHeaderCmd, ctx: Ctx) -> Result<(), CliError> { .await?; ctx.out .note(&format!("✓ Set IP header {header_name:?} on {id}")); + warn_if_option_disabled( + &ctx, + &id, + "ipCustomHeader", + "ip-custom-header", + "this header", + ) + .await; } IpHeaderCmd::Remove { id } => { confirm_mild( diff --git a/src/output.rs b/src/output.rs index 7b5291a..86084e5 100644 --- a/src/output.rs +++ b/src/output.rs @@ -17,8 +17,9 @@ //! Color is suppressed when any of: `--no-color`, `NO_COLOR` env, `TERM=dumb`, //! stdout is not a TTY, or the format is anything other than `table`. //! -//! State-change confirmations go to stderr through [`OutputCtx::note`]; only -//! `--quiet` suppresses them. +//! State-change confirmations go to stderr through [`OutputCtx::note`], and +//! advisory warnings through [`OutputCtx::warn`]; only `--quiet` suppresses +//! them. use std::io::{IsTerminal, Write}; @@ -121,6 +122,15 @@ impl OutputCtx { } let _ = writeln!(std::io::stderr(), "{message}"); } + + /// Writes an advisory warning to stderr (e.g. "⚠ option is disabled…"). + /// Suppressed under `--quiet`, like [`note`](Self::note). + pub fn warn(&self, message: &str) { + if self.quiet { + return; + } + let _ = writeln!(std::io::stderr(), "{message}"); + } } /// Trait every printable response implements. diff --git a/tests/endpoint.rs b/tests/endpoint.rs index 4c50614..6d7956b 100644 --- a/tests/endpoint.rs +++ b/tests/endpoint.rs @@ -421,6 +421,220 @@ async fn endpoint_security_token_create() { assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } +fn security_options_payload(option: &str, status: &str) -> serde_json::Value { + json!({ + "data": [{ "option": option, "status": status, "value": null }], + "error": null, + }) +} + +#[tokio::test] +async fn endpoint_security_referrer_add_warns_when_option_disabled() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/referrers")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(security_options_payload("referrers", "disabled")), + ) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "referrer", "add", "ep-1", "foo.com"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_referrer_add_skips_options_check_when_quiet() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/referrers")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "--quiet", "endpoint", "security", "referrer", "add", "ep-1", "foo.com", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_referrer_add_succeeds_when_options_check_fails() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/referrers")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "referrer", "add", "ep-1", "foo.com"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_domain_mask_add_checks_options() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/domain_masks")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(security_options_payload("domainMasks", "enabled")), + ) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "domain-mask", + "add", + "ep-1", + "*.example.com", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +/// Subprocess test: the post-add warning lands on stderr with the exact +/// enable command when the governing option is disabled. +#[tokio::test] +async fn security_referrer_add_disabled_option_warning_on_stderr() { + use assert_cmd::Command; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/referrers")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(security_options_payload("referrers", "disabled")), + ) + .mount(&server) + .await; + + let output = Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args([ + "--api-key", + "test", + "--base-url", + &server.uri(), + "--no-input", + "endpoint", + "security", + "referrer", + "add", + "ep-1", + "foo.com", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stderr={stderr}"); + assert!( + stderr.contains("security option is disabled"), + "stderr missing warning:\n{stderr}" + ); + assert!( + stderr.contains("qn endpoint security set-options --referrers enabled ep-1"), + "stderr missing enable hint:\n{stderr}" + ); +} + +/// Subprocess test: no warning when the governing option is enabled. +#[tokio::test] +async fn security_referrer_add_enabled_option_no_warning_on_stderr() { + use assert_cmd::Command; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/endpoints/ep-1/security/referrers")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/ep-1/security_options")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(security_options_payload("referrers", "enabled")), + ) + .mount(&server) + .await; + + let output = Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args([ + "--api-key", + "test", + "--base-url", + &server.uri(), + "--no-input", + "endpoint", + "security", + "referrer", + "add", + "ep-1", + "foo.com", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stderr={stderr}"); + assert!( + stderr.contains("✓ Whitelisted referrer"), + "stderr missing success note:\n{stderr}" + ); + assert!( + !stderr.contains('⚠'), + "stderr unexpectedly contains a warning:\n{stderr}" + ); +} + #[tokio::test] async fn endpoint_security_token_delete_with_yes() { let server = MockServer::start().await;