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
113 changes: 108 additions & 5 deletions src/cloud/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ fn parse_enum<T>(value: &str, field: &str) -> Result<T, Box<dyn std::error::Erro
where
T: FromStr<Err = String>,
{
T::from_str(value).map_err(|err| format!("invalid {} '{}': {}", field, value, err).into())
T::from_str(value).map_err(|err| format!("invalid {}: {}", field, err).into())
}

fn parse_tag(value: &str) -> Result<ResourceTag, Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -641,8 +641,10 @@ pub async fn service_create(
opts: CreateServiceOptions,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;
// Validate input before any network call so typos like --provider awss
// fail locally instead of on the /organizations lookup.
let request = build_create_service_request(&opts)?;
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;

let response = client.create_service(&org_id, &request).await?;

Expand Down Expand Up @@ -877,8 +879,10 @@ pub async fn service_update(
opts: ServiceUpdateOptions,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;
// Validate input before any network call so typos like --release-channel turbo
// fail locally instead of on the /organizations lookup.
let request = build_update_service_request(&opts)?;
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;

let svc = client.update_service(&org_id, service_id, &request).await?;

Expand Down Expand Up @@ -1458,8 +1462,10 @@ pub async fn key_create(
opts: KeyCreateOptions,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;
// Validate input before any network call so typos like --state broken
// fail locally instead of on the /organizations lookup.
let request = build_api_key_create_request(&opts)?;
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;

let resp = client.create_api_key(&org_id, &request).await?;

Expand Down Expand Up @@ -1519,8 +1525,10 @@ pub async fn key_update(
opts: KeyUpdateOptions,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;
// Validate input before any network call so typos like --state broken
// fail locally instead of on the /organizations lookup.
let request = build_api_key_update_request(&opts)?;
let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?;

let key = client.update_api_key(&org_id, key_id, &request).await?;

Expand Down Expand Up @@ -2098,6 +2106,101 @@ mod tests {
assert_eq!(backup_json["backupStartTime"], "03:00");
}

// Regression tests: invalid enum values must be rejected by build_* functions
// before any network call (resolve_org_id). See issue #101.

#[test]
fn build_create_service_request_rejects_invalid_provider() {
let opts = CreateServiceOptions {
name: "svc".to_string(),
provider: "awss".to_string(),
region: "us-east-1".to_string(),
..Default::default()
};
let err = build_create_service_request(&opts).unwrap_err();
assert!(
err.to_string().contains("awss"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_create_service_request_rejects_invalid_region() {
let opts = CreateServiceOptions {
name: "svc".to_string(),
provider: "aws".to_string(),
region: "us-east-99".to_string(),
..Default::default()
};
let err = build_create_service_request(&opts).unwrap_err();
assert!(
err.to_string().contains("us-east-99"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_create_service_request_rejects_invalid_release_channel() {
let opts = CreateServiceOptions {
name: "svc".to_string(),
provider: "aws".to_string(),
region: "us-east-1".to_string(),
release_channel: Some("turbo".to_string()),
..Default::default()
};
let err = build_create_service_request(&opts).unwrap_err();
assert!(
err.to_string().contains("turbo"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_update_service_request_rejects_invalid_release_channel() {
let opts = ServiceUpdateOptions {
release_channel: Some("turbo".to_string()),
..Default::default()
};
let err = build_update_service_request(&opts).unwrap_err();
assert!(
err.to_string().contains("turbo"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_api_key_create_request_rejects_invalid_state() {
let opts = KeyCreateOptions {
name: "ci-key".to_string(),
state: Some("broken".to_string()),
..Default::default()
};
let err = build_api_key_create_request(&opts).unwrap_err();
assert!(
err.to_string().contains("broken"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_api_key_update_request_rejects_invalid_state() {
let opts = KeyUpdateOptions {
state: Some("broken".to_string()),
..Default::default()
};
let err = build_api_key_update_request(&opts).unwrap_err();
assert!(
err.to_string().contains("broken"),
"error should mention the bad value: {}",
err
);
}

#[test]
fn build_password_and_query_endpoint_requests_use_new_fields() {
let password_request = build_service_password_patch_request(&ServiceResetPasswordOptions {
Expand Down
25 changes: 20 additions & 5 deletions src/cloud/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ macro_rules! string_enum {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
$($value => Ok(Self::$variant),)+
_ => Err(format!("invalid {}: {}", stringify!($name), s)),
_ => Err(format!(
"unknown value '{}', expected one of: {}",
s,
[$($value),+].join(", ")
)),
}
}
}
Expand Down Expand Up @@ -112,14 +116,25 @@ macro_rules! flexible_string_enum {
}
}

impl $name {
/// Returns the list of known string values for this enum.
pub fn known_values() -> &'static [&'static str] {
&[$($value),+]
}
}

impl FromStr for $name {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
$($value => Self::$variant,)+
other => Self::Other(other.to_string()),
})
match s {
$($value => Ok(Self::$variant),)+
_ => Err(format!(
"unknown value '{}', expected one of: {}",
s,
[$($value),+].join(", ")
)),
}
}
}

Expand Down
77 changes: 77 additions & 0 deletions src/cloud/types_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::types::*;
use std::str::FromStr;

// Contract policy for this suite:
// - mirror GA request/response schemas and query surfaces only
Expand Down Expand Up @@ -1845,3 +1846,79 @@ fn test_service_query_endpoint_role_values() {
assert_eq!(ep.roles.as_ref().unwrap()[0], *role);
}
}

// --- FromStr rejects unknown values for user input validation ---

#[test]
fn test_cloud_provider_fromstr_rejects_unknown() {
assert!(CloudProvider::from_str("aws").is_ok());
assert!(CloudProvider::from_str("gcp").is_ok());
assert!(CloudProvider::from_str("azure").is_ok());
let err = CloudProvider::from_str("awss").unwrap_err();
assert!(err.contains("awss"), "error should mention the bad value");
assert!(err.contains("aws"), "error should list valid values");
}

#[test]
fn test_cloud_region_fromstr_rejects_unknown() {
assert!(CloudRegion::from_str("us-east-1").is_ok());
let err = CloudRegion::from_str("us-east-99").unwrap_err();
assert!(err.contains("us-east-99"));
}

#[test]
fn test_service_tier_fromstr_rejects_unknown() {
assert!(ServiceTier::from_str("production").is_ok());
assert!(ServiceTier::from_str("development").is_ok());
let err = ServiceTier::from_str("productoin").unwrap_err();
assert!(err.contains("productoin"));
}

#[test]
fn test_release_channel_fromstr_rejects_unknown() {
assert!(ReleaseChannel::from_str("slow").is_ok());
assert!(ReleaseChannel::from_str("default").is_ok());
assert!(ReleaseChannel::from_str("fast").is_ok());
let err = ReleaseChannel::from_str("turbo").unwrap_err();
assert!(err.contains("turbo"));
assert!(err.contains("slow"));
}

#[test]
fn test_compliance_type_fromstr_rejects_unknown() {
assert!(ComplianceType::from_str("hipaa").is_ok());
assert!(ComplianceType::from_str("pci").is_ok());
let err = ComplianceType::from_str("soc2").unwrap_err();
assert!(err.contains("soc2"));
}

#[test]
fn test_service_profile_fromstr_rejects_unknown() {
assert!(ServiceProfile::from_str("v1-default").is_ok());
let err = ServiceProfile::from_str("v2-default").unwrap_err();
assert!(err.contains("v2-default"));
}

#[test]
fn test_flexible_enum_known_values() {
let values = CloudProvider::known_values();
assert_eq!(values, &["aws", "gcp", "azure"]);
}

// --- Deserialization still accepts unknown values from API responses ---

#[test]
fn test_flexible_enum_deserialize_accepts_unknown() {
// API responses can contain new values the CLI doesn't know about
let json = serde_json::json!({
"id": "svc-1",
"name": "svc",
"provider": "oracle",
"region": "mars-west-1",
"state": "quantum-superposition"
});
let svc: Service = serde_json::from_value(json).unwrap();
assert_eq!(svc.provider, "oracle");
assert_eq!(svc.region, "mars-west-1");
assert_eq!(svc.state, "quantum-superposition");
}
2 changes: 1 addition & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub enum Error {
#[error("Server '{0}' is already running")]
ServerAlreadyRunning(String),

#[error("Cloud API error: {0}")]
#[error("{0}")]
Cloud(String),

#[error("{0}")]
Expand Down
Loading