Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/commands/endpoint/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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}?"))?;
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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.
Expand Down
214 changes: 214 additions & 0 deletions tests/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading