Skip to content

Commit 12b685a

Browse files
authored
feat(endpoint): warn when added security item's option toggle is disabled (DX-5701) (#33)
Items added under a disabled security option are not enforced and do not appear in list output until the option is enabled via set-options, so a plain success message made them look like they silently vanished. After every toggle-governed add/set (token create, referrer add, ip add, jwt add, domain-mask add, request-filter create, ip-header set), the CLI now makes one best-effort GET of the endpoint's security options and, if the governing option is disabled, prints a stderr warning with the exact 'qn endpoint security set-options --<option> enabled <ID>' command. The check never blocks: a failed lookup is swallowed (the add already succeeded), and --quiet skips the extra request entirely. Adds OutputCtx::warn for advisory stderr messages alongside note. Tests: 4 in-process (disabled warns, quiet skips the GET, failed check still exits 0, second command coverage) + 2 subprocess stderr-content tests (warning text with enable hint; no warning when enabled).
1 parent 6d43e54 commit 12b685a

3 files changed

Lines changed: 273 additions & 2 deletions

File tree

src/commands/endpoint/security.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,37 @@ async fn set_options(a: SetOptionsArgs, ctx: Ctx) -> Result<(), CliError> {
308308
crate::output::emit(&ctx.out, &SecurityOptionsListView(resp.data))
309309
}
310310

311+
/// Best-effort post-add check: if the security option governing the item just
312+
/// added is disabled, the item is not enforced and won't appear in list output,
313+
/// so warn on stderr with the exact enable command. Never fails the command —
314+
/// any error fetching the options is swallowed (the add already succeeded),
315+
/// and the lookup is skipped entirely under `--quiet`.
316+
async fn warn_if_option_disabled(ctx: &Ctx, id: &str, option: &str, flag: &str, item_desc: &str) {
317+
if ctx.out.quiet {
318+
return;
319+
}
320+
let Ok(resp) = ctx.sdk.admin.get_security_options(id).await else {
321+
return;
322+
};
323+
if resp
324+
.data
325+
.iter()
326+
.any(|o| o.option == option && o.status == "disabled")
327+
{
328+
ctx.out.warn(&format!(
329+
"⚠ The '{option}' security option is disabled on {id} —\n \
330+
{item_desc} will have no effect until you enable it:\n \
331+
qn endpoint security set-options --{flag} enabled {id}"
332+
));
333+
}
334+
}
335+
311336
async fn token(cmd: TokenCmd, ctx: Ctx) -> Result<(), CliError> {
312337
match cmd {
313338
TokenCmd::Create { id } => {
314339
ctx.sdk.admin.create_token(&id).await?;
315340
ctx.out.note(&format!("✓ Created token on {id}"));
341+
warn_if_option_disabled(&ctx, &id, "tokens", "tokens", "this token").await;
316342
}
317343
TokenCmd::Delete { id, token_id } => {
318344
confirm_mild(
@@ -337,6 +363,7 @@ async fn referrer(cmd: ReferrerCmd, ctx: Ctx) -> Result<(), CliError> {
337363
ctx.sdk.admin.create_referrer(&id, &req).await?;
338364
ctx.out
339365
.note(&format!("✓ Whitelisted referrer {referrer:?} on {id}"));
366+
warn_if_option_disabled(&ctx, &id, "referrers", "referrers", "this referrer").await;
340367
}
341368
ReferrerCmd::Remove { id, referrer_id } => {
342369
confirm_mild(
@@ -359,6 +386,7 @@ async fn ip(cmd: IpCmd, ctx: Ctx) -> Result<(), CliError> {
359386
};
360387
ctx.sdk.admin.create_ip(&id, &req).await?;
361388
ctx.out.note(&format!("✓ Whitelisted IP {ip} on {id}"));
389+
warn_if_option_disabled(&ctx, &id, "ips", "ips", "this IP").await;
362390
}
363391
IpCmd::Remove { id, ip_id } => {
364392
confirm_mild(
@@ -396,6 +424,7 @@ async fn jwt(cmd: JwtCmd, ctx: Ctx) -> Result<(), CliError> {
396424
};
397425
ctx.sdk.admin.create_jwt(&a.id, &req).await?;
398426
ctx.out.note(&format!("✓ Added JWT on {}", a.id));
427+
warn_if_option_disabled(&ctx, &a.id, "jwts", "jwts", "this JWT").await;
399428
}
400429
JwtCmd::Remove { id, jwt_id } => {
401430
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> {
415444
ctx.sdk.admin.create_domain_mask(&id, &req).await?;
416445
ctx.out
417446
.note(&format!("✓ Added domain mask {domain:?} on {id}"));
447+
warn_if_option_disabled(&ctx, &id, "domainMasks", "domain-masks", "this domain mask")
448+
.await;
418449
}
419450
DomainMaskCmd::Remove { id, domain_mask_id } => {
420451
confirm_mild(
@@ -449,6 +480,14 @@ async fn request_filter(cmd: RequestFilterCmd, ctx: Ctx) -> Result<(), CliError>
449480
})?;
450481
ctx.out
451482
.note(&format!("✓ Created request filter {} on {}", d.id, a.id));
483+
warn_if_option_disabled(
484+
&ctx,
485+
&a.id,
486+
"requestFilters",
487+
"request-filters",
488+
"this request filter",
489+
)
490+
.await;
452491
}
453492
RequestFilterCmd::Update(a) => {
454493
let mut methods = a.methods;
@@ -497,6 +536,14 @@ async fn ip_header(cmd: IpHeaderCmd, ctx: Ctx) -> Result<(), CliError> {
497536
.await?;
498537
ctx.out
499538
.note(&format!("✓ Set IP header {header_name:?} on {id}"));
539+
warn_if_option_disabled(
540+
&ctx,
541+
&id,
542+
"ipCustomHeader",
543+
"ip-custom-header",
544+
"this header",
545+
)
546+
.await;
500547
}
501548
IpHeaderCmd::Remove { id } => {
502549
confirm_mild(

src/output.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
//! Color is suppressed when any of: `--no-color`, `NO_COLOR` env, `TERM=dumb`,
1818
//! stdout is not a TTY, or the format is anything other than `table`.
1919
//!
20-
//! State-change confirmations go to stderr through [`OutputCtx::note`]; only
21-
//! `--quiet` suppresses them.
20+
//! State-change confirmations go to stderr through [`OutputCtx::note`], and
21+
//! advisory warnings through [`OutputCtx::warn`]; only `--quiet` suppresses
22+
//! them.
2223
2324
use std::io::{IsTerminal, Write};
2425

@@ -121,6 +122,15 @@ impl OutputCtx {
121122
}
122123
let _ = writeln!(std::io::stderr(), "{message}");
123124
}
125+
126+
/// Writes an advisory warning to stderr (e.g. "⚠ option is disabled…").
127+
/// Suppressed under `--quiet`, like [`note`](Self::note).
128+
pub fn warn(&self, message: &str) {
129+
if self.quiet {
130+
return;
131+
}
132+
let _ = writeln!(std::io::stderr(), "{message}");
133+
}
124134
}
125135

126136
/// Trait every printable response implements.

tests/endpoint.rs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,220 @@ async fn endpoint_security_token_create() {
421421
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
422422
}
423423

424+
fn security_options_payload(option: &str, status: &str) -> serde_json::Value {
425+
json!({
426+
"data": [{ "option": option, "status": status, "value": null }],
427+
"error": null,
428+
})
429+
}
430+
431+
#[tokio::test]
432+
async fn endpoint_security_referrer_add_warns_when_option_disabled() {
433+
let server = MockServer::start().await;
434+
Mock::given(method("POST"))
435+
.and(path("/v0/endpoints/ep-1/security/referrers"))
436+
.respond_with(ResponseTemplate::new(200))
437+
.expect(1)
438+
.mount(&server)
439+
.await;
440+
Mock::given(method("GET"))
441+
.and(path("/v0/endpoints/ep-1/security_options"))
442+
.respond_with(
443+
ResponseTemplate::new(200)
444+
.set_body_json(security_options_payload("referrers", "disabled")),
445+
)
446+
.expect(1)
447+
.mount(&server)
448+
.await;
449+
let out = run_qn(
450+
&server.uri(),
451+
&["endpoint", "security", "referrer", "add", "ep-1", "foo.com"],
452+
)
453+
.await;
454+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
455+
}
456+
457+
#[tokio::test]
458+
async fn endpoint_security_referrer_add_skips_options_check_when_quiet() {
459+
let server = MockServer::start().await;
460+
Mock::given(method("POST"))
461+
.and(path("/v0/endpoints/ep-1/security/referrers"))
462+
.respond_with(ResponseTemplate::new(200))
463+
.expect(1)
464+
.mount(&server)
465+
.await;
466+
Mock::given(method("GET"))
467+
.and(path("/v0/endpoints/ep-1/security_options"))
468+
.respond_with(ResponseTemplate::new(200))
469+
.expect(0)
470+
.mount(&server)
471+
.await;
472+
let out = run_qn(
473+
&server.uri(),
474+
&[
475+
"--quiet", "endpoint", "security", "referrer", "add", "ep-1", "foo.com",
476+
],
477+
)
478+
.await;
479+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
480+
}
481+
482+
#[tokio::test]
483+
async fn endpoint_security_referrer_add_succeeds_when_options_check_fails() {
484+
let server = MockServer::start().await;
485+
Mock::given(method("POST"))
486+
.and(path("/v0/endpoints/ep-1/security/referrers"))
487+
.respond_with(ResponseTemplate::new(200))
488+
.expect(1)
489+
.mount(&server)
490+
.await;
491+
Mock::given(method("GET"))
492+
.and(path("/v0/endpoints/ep-1/security_options"))
493+
.respond_with(ResponseTemplate::new(500))
494+
.mount(&server)
495+
.await;
496+
let out = run_qn(
497+
&server.uri(),
498+
&["endpoint", "security", "referrer", "add", "ep-1", "foo.com"],
499+
)
500+
.await;
501+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
502+
}
503+
504+
#[tokio::test]
505+
async fn endpoint_security_domain_mask_add_checks_options() {
506+
let server = MockServer::start().await;
507+
Mock::given(method("POST"))
508+
.and(path("/v0/endpoints/ep-1/security/domain_masks"))
509+
.respond_with(ResponseTemplate::new(200))
510+
.expect(1)
511+
.mount(&server)
512+
.await;
513+
Mock::given(method("GET"))
514+
.and(path("/v0/endpoints/ep-1/security_options"))
515+
.respond_with(
516+
ResponseTemplate::new(200)
517+
.set_body_json(security_options_payload("domainMasks", "enabled")),
518+
)
519+
.expect(1)
520+
.mount(&server)
521+
.await;
522+
let out = run_qn(
523+
&server.uri(),
524+
&[
525+
"endpoint",
526+
"security",
527+
"domain-mask",
528+
"add",
529+
"ep-1",
530+
"*.example.com",
531+
],
532+
)
533+
.await;
534+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
535+
}
536+
537+
/// Subprocess test: the post-add warning lands on stderr with the exact
538+
/// enable command when the governing option is disabled.
539+
#[tokio::test]
540+
async fn security_referrer_add_disabled_option_warning_on_stderr() {
541+
use assert_cmd::Command;
542+
let server = MockServer::start().await;
543+
Mock::given(method("POST"))
544+
.and(path("/v0/endpoints/ep-1/security/referrers"))
545+
.respond_with(ResponseTemplate::new(200))
546+
.mount(&server)
547+
.await;
548+
Mock::given(method("GET"))
549+
.and(path("/v0/endpoints/ep-1/security_options"))
550+
.respond_with(
551+
ResponseTemplate::new(200)
552+
.set_body_json(security_options_payload("referrers", "disabled")),
553+
)
554+
.mount(&server)
555+
.await;
556+
557+
let output = Command::cargo_bin("qn")
558+
.unwrap()
559+
.env_remove("HOME")
560+
.env("HOME", std::env::temp_dir())
561+
.args([
562+
"--api-key",
563+
"test",
564+
"--base-url",
565+
&server.uri(),
566+
"--no-input",
567+
"endpoint",
568+
"security",
569+
"referrer",
570+
"add",
571+
"ep-1",
572+
"foo.com",
573+
])
574+
.output()
575+
.unwrap();
576+
let stderr = String::from_utf8_lossy(&output.stderr);
577+
assert!(output.status.success(), "stderr={stderr}");
578+
assert!(
579+
stderr.contains("security option is disabled"),
580+
"stderr missing warning:\n{stderr}"
581+
);
582+
assert!(
583+
stderr.contains("qn endpoint security set-options --referrers enabled ep-1"),
584+
"stderr missing enable hint:\n{stderr}"
585+
);
586+
}
587+
588+
/// Subprocess test: no warning when the governing option is enabled.
589+
#[tokio::test]
590+
async fn security_referrer_add_enabled_option_no_warning_on_stderr() {
591+
use assert_cmd::Command;
592+
let server = MockServer::start().await;
593+
Mock::given(method("POST"))
594+
.and(path("/v0/endpoints/ep-1/security/referrers"))
595+
.respond_with(ResponseTemplate::new(200))
596+
.mount(&server)
597+
.await;
598+
Mock::given(method("GET"))
599+
.and(path("/v0/endpoints/ep-1/security_options"))
600+
.respond_with(
601+
ResponseTemplate::new(200)
602+
.set_body_json(security_options_payload("referrers", "enabled")),
603+
)
604+
.mount(&server)
605+
.await;
606+
607+
let output = Command::cargo_bin("qn")
608+
.unwrap()
609+
.env_remove("HOME")
610+
.env("HOME", std::env::temp_dir())
611+
.args([
612+
"--api-key",
613+
"test",
614+
"--base-url",
615+
&server.uri(),
616+
"--no-input",
617+
"endpoint",
618+
"security",
619+
"referrer",
620+
"add",
621+
"ep-1",
622+
"foo.com",
623+
])
624+
.output()
625+
.unwrap();
626+
let stderr = String::from_utf8_lossy(&output.stderr);
627+
assert!(output.status.success(), "stderr={stderr}");
628+
assert!(
629+
stderr.contains("✓ Whitelisted referrer"),
630+
"stderr missing success note:\n{stderr}"
631+
);
632+
assert!(
633+
!stderr.contains('⚠'),
634+
"stderr unexpectedly contains a warning:\n{stderr}"
635+
);
636+
}
637+
424638
#[tokio::test]
425639
async fn endpoint_security_token_delete_with_yes() {
426640
let server = MockServer::start().await;

0 commit comments

Comments
 (0)