From 2a944d9244b905e98ff501443131787c24b51ce9 Mon Sep 17 00:00:00 2001 From: overtrue Date: Sun, 19 Apr 2026 23:38:08 +0800 Subject: [PATCH] test(cli): cover remaining parser and help gaps --- crates/cli/src/commands/cors.rs | 107 +++++++++++ crates/cli/src/commands/event.rs | 7 + crates/cli/src/commands/mod.rs | 305 ++++++++++++++++++++++++++++++ crates/cli/tests/help_contract.rs | 33 ++++ 4 files changed, 452 insertions(+) diff --git a/crates/cli/src/commands/cors.rs b/crates/cli/src/commands/cors.rs index 976869d..aa35ad6 100644 --- a/crates/cli/src/commands/cors.rs +++ b/crates/cli/src/commands/cors.rs @@ -534,6 +534,7 @@ mod tests { assert!(parse_bucket_path("local").is_err()); assert!(parse_bucket_path("local/").is_err()); assert!(parse_bucket_path("/bucket").is_err()); + assert!(parse_bucket_path("local//").is_err()); assert!(parse_bucket_path("local//bucket").is_err()); assert!(parse_bucket_path("local/my-bucket/nested").is_err()); assert!(parse_bucket_path("local///").is_err()); @@ -595,6 +596,40 @@ mod tests { assert!(error.contains("unsupported method")); } + #[test] + fn test_parse_cors_configuration_rejects_missing_allowed_origin() { + let error = parse_cors_configuration( + r#"{ + "rules": [ + { + "allowedOrigins": [], + "allowedMethods": ["GET"] + } + ] + }"#, + ) + .expect_err("missing allowed origin"); + + assert!(error.contains("at least one allowed origin")); + } + + #[test] + fn test_parse_cors_configuration_rejects_missing_allowed_method() { + let error = parse_cors_configuration( + r#"{ + "rules": [ + { + "allowedOrigins": ["*"], + "allowedMethods": [] + } + ] + }"#, + ) + .expect_err("missing allowed method"); + + assert!(error.contains("at least one allowed method")); + } + #[test] fn test_parse_cors_configuration_xml_rejects_missing_allowed_origin() { let error = parse_cors_configuration( @@ -715,6 +750,28 @@ mod tests { assert_eq!(config.rules[0].allowed_methods, vec!["GET".to_string()]); } + #[test] + fn test_parse_cors_configuration_xml_drops_blank_optional_headers() { + let config = parse_cors_configuration( + r#" + + + https://console.example.com + get + + + + +"#, + ) + .expect("parse xml config with blank optional headers"); + + assert_eq!(config.rules.len(), 1); + assert_eq!(config.rules[0].allowed_headers, None); + assert_eq!(config.rules[0].expose_headers, None); + assert_eq!(config.rules[0].allowed_methods, vec!["GET".to_string()]); + } + #[test] fn test_cors_input_source_prefers_positional_argument() { let args = SetCorsArgs { @@ -763,6 +820,56 @@ mod tests { assert!(cors_input_source(&args).is_err()); } + #[tokio::test] + async fn test_execute_list_rejects_empty_normalized_bucket_path() { + let code = execute( + CorsArgs { + command: CorsCommands::List(BucketArg { + path: "local///".to_string(), + force: false, + }), + }, + OutputConfig::default(), + ) + .await; + + assert_eq!(code, ExitCode::UsageError); + } + + #[tokio::test] + async fn test_execute_set_rejects_empty_normalized_bucket_path_before_reading_source() { + let code = execute( + CorsArgs { + command: CorsCommands::Set(SetCorsArgs { + path: "local///".to_string(), + source: Some("missing-cors.json".to_string()), + file: None, + force: false, + }), + }, + OutputConfig::default(), + ) + .await; + + assert_eq!(code, ExitCode::UsageError); + } + + #[tokio::test] + async fn test_execute_remove_rejects_empty_normalized_bucket_path() { + let code = execute( + CorsArgs { + command: CorsCommands::Remove(BucketArg { + path: "local///".to_string(), + force: false, + }), + }, + OutputConfig::default(), + ) + .await; + + assert_eq!(code, ExitCode::UsageError); + } + #[tokio::test] async fn test_read_cors_source_reads_file_contents() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/crates/cli/src/commands/event.rs b/crates/cli/src/commands/event.rs index 52d8601..359b985 100644 --- a/crates/cli/src/commands/event.rs +++ b/crates/cli/src/commands/event.rs @@ -22,6 +22,11 @@ Examples: rc event add local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts --event delete rc bucket event add local/my-bucket arn:aws:lambda:us-east-1:123456789012:function:thumbnail --event put,delete"; +const EVENT_REMOVE_AFTER_HELP: &str = "\ +Examples: + rc bucket event remove local/my-bucket arn:aws:sqs:us-east-1:123456789012:jobs + rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts"; + /// Manage bucket event notifications #[derive(Args, Debug)] #[command(after_help = EVENT_AFTER_HELP)] @@ -71,6 +76,7 @@ pub struct AddArgs { } #[derive(Args, Debug)] +#[command(after_help = EVENT_REMOVE_AFTER_HELP)] pub struct RemoveArgs { /// Path to the bucket (alias/bucket) pub path: String, @@ -480,6 +486,7 @@ mod tests { assert!(parse_bucket_path("local").is_err()); assert!(parse_bucket_path("/bucket").is_err()); assert!(parse_bucket_path("local/my-bucket/prefix").is_err()); + assert!(parse_bucket_path("local///").is_err()); } #[test] diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9668048..5d0ba6d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -612,6 +612,64 @@ mod tests { } } + #[test] + fn cli_accepts_top_level_cors_set_with_positional_source() { + let cli = Cli::try_parse_from(["rc", "cors", "set", "local/my-bucket", "cors.xml"]) + .expect("parse top-level cors set with positional source"); + + match cli.command { + Commands::Cors(cors::CorsCommands::Set(arg)) => { + assert_eq!(arg.path, "local/my-bucket"); + assert_eq!(arg.source.as_deref(), Some("cors.xml")); + assert_eq!(arg.file, None); + assert!(!arg.force); + } + other => panic!("expected top-level cors set command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_top_level_cors_set_with_legacy_file_flag() { + let cli = Cli::try_parse_from([ + "rc", + "cors", + "set", + "local/my-bucket", + "--file", + "cors.json", + "--force", + ]) + .expect("parse top-level cors set with --file"); + + match cli.command { + Commands::Cors(cors::CorsCommands::Set(arg)) => { + assert_eq!(arg.path, "local/my-bucket"); + assert_eq!(arg.source, None); + assert_eq!(arg.file.as_deref(), Some("cors.json")); + assert!(arg.force); + } + other => panic!("expected top-level cors set command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_bucket_cors_list_force_flag() { + let cli = + Cli::try_parse_from(["rc", "bucket", "cors", "list", "local/my-bucket", "--force"]) + .expect("parse bucket cors list with force"); + + match cli.command { + Commands::Bucket(args) => match args.command { + bucket::BucketCommands::Cors(cors::CorsCommands::List(arg)) => { + assert_eq!(arg.path, "local/my-bucket"); + assert!(arg.force); + } + other => panic!("expected bucket cors list command, got {:?}", other), + }, + other => panic!("expected bucket command, got {:?}", other), + } + } + #[test] fn cli_accepts_bucket_lifecycle_subcommand() { let cli = Cli::try_parse_from([ @@ -701,4 +759,251 @@ mod tests { other => panic!("expected object command, got {:?}", other), } } + + #[test] + fn cli_accepts_bucket_event_remove_subcommand() { + let cli = Cli::try_parse_from([ + "rc", + "bucket", + "event", + "remove", + "local/my-bucket", + "arn:aws:sns:us-east-1:123456789012:alerts", + ]) + .expect("parse bucket event remove"); + + match cli.command { + Commands::Bucket(args) => match args.command { + bucket::BucketCommands::Event(event::EventCommands::Remove(arg)) => { + assert_eq!(arg.path, "local/my-bucket"); + assert_eq!(arg.arn, "arn:aws:sns:us-east-1:123456789012:alerts"); + } + other => panic!("expected bucket event remove command, got {:?}", other), + }, + other => panic!("expected bucket command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_rm_purge_flag() { + let cli = Cli::try_parse_from(["rc", "rm", "local/my-bucket/object.txt", "--purge"]) + .expect("parse rm purge"); + + match cli.command { + Commands::Rm(arg) => { + assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]); + assert!(arg.purge); + } + other => panic!("expected rm command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_remove_purge_flag() { + let cli = Cli::try_parse_from([ + "rc", + "object", + "remove", + "local/my-bucket/object.txt", + "--purge", + ]) + .expect("parse object remove purge"); + + match cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Remove(arg) => { + assert_eq!(arg.paths, vec!["local/my-bucket/object.txt".to_string()]); + assert!(arg.purge); + } + other => panic!("expected object remove command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_stat_subcommand() { + let cli = Cli::try_parse_from(["rc", "object", "stat", "local/my-bucket/report.json"]) + .expect("parse object stat"); + + match cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Stat(arg) => { + assert_eq!(arg.path, "local/my-bucket/report.json"); + } + other => panic!("expected object stat command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_copy_with_transfer_options() { + let cli = Cli::try_parse_from([ + "rc", + "object", + "copy", + "./report.json", + "local/my-bucket/reports/", + "--content-type", + "application/json", + "--storage-class", + "STANDARD_IA", + "--dry-run", + ]) + .expect("parse object copy with transfer options"); + + match cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Copy(arg) => { + assert_eq!(arg.source, "./report.json"); + assert_eq!(arg.target, "local/my-bucket/reports/"); + assert_eq!(arg.content_type.as_deref(), Some("application/json")); + assert_eq!(arg.storage_class.as_deref(), Some("STANDARD_IA")); + assert!(arg.dry_run); + } + other => panic!("expected object copy command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_move_with_recursive_dry_run() { + let cli = Cli::try_parse_from([ + "rc", + "object", + "move", + "local/source-bucket/logs/", + "local/archive-bucket/logs/", + "--recursive", + "--dry-run", + "--continue-on-error", + ]) + .expect("parse object move with recursive dry-run"); + + match cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Move(arg) => { + assert_eq!(arg.source, "local/source-bucket/logs/"); + assert_eq!(arg.target, "local/archive-bucket/logs/"); + assert!(arg.recursive); + assert!(arg.dry_run); + assert!(arg.continue_on_error); + } + other => panic!("expected object move command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_show_and_head_options() { + let show_cli = Cli::try_parse_from([ + "rc", + "object", + "show", + "local/my-bucket/report.json", + "--version-id", + "v1", + "--rewind", + "1h", + ]) + .expect("parse object show options"); + + match show_cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Show(arg) => { + assert_eq!(arg.path, "local/my-bucket/report.json"); + assert_eq!(arg.version_id.as_deref(), Some("v1")); + assert_eq!(arg.rewind.as_deref(), Some("1h")); + } + other => panic!("expected object show command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + + let head_cli = Cli::try_parse_from([ + "rc", + "object", + "head", + "local/my-bucket/report.json", + "--bytes", + "128", + "--version-id", + "v2", + ]) + .expect("parse object head options"); + + match head_cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Head(arg) => { + assert_eq!(arg.path, "local/my-bucket/report.json"); + assert_eq!(arg.bytes, Some(128)); + assert_eq!(arg.version_id.as_deref(), Some("v2")); + } + other => panic!("expected object head command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } + + #[test] + fn cli_accepts_object_find_and_tree_options() { + let find_cli = Cli::try_parse_from([ + "rc", + "object", + "find", + "local/my-bucket/logs/", + "--name", + "*.json", + "--maxdepth", + "2", + "--count", + "--print", + ]) + .expect("parse object find options"); + + match find_cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Find(arg) => { + assert_eq!(arg.path, "local/my-bucket/logs/"); + assert_eq!(arg.name.as_deref(), Some("*.json")); + assert_eq!(arg.maxdepth, 2); + assert!(arg.count); + assert!(arg.print); + } + other => panic!("expected object find command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + + let tree_cli = Cli::try_parse_from([ + "rc", + "object", + "tree", + "local/my-bucket/logs/", + "--level", + "4", + "--size", + "--pattern", + "*.json", + "--full-path", + ]) + .expect("parse object tree options"); + + match tree_cli.command { + Commands::Object(args) => match args.command { + object::ObjectCommands::Tree(arg) => { + assert_eq!(arg.path, "local/my-bucket/logs/"); + assert_eq!(arg.level, 4); + assert!(arg.size); + assert_eq!(arg.pattern.as_deref(), Some("*.json")); + assert!(arg.full_path); + } + other => panic!("expected object tree command, got {:?}", other), + }, + other => panic!("expected object command, got {:?}", other), + } + } } diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs index bd1662b..45da61d 100644 --- a/crates/cli/tests/help_contract.rs +++ b/crates/cli/tests/help_contract.rs @@ -472,11 +472,25 @@ fn nested_subcommand_help_contract() { "rc bucket event add local/my-bucket arn:aws:sqs:us-east-1:123456789012:jobs --event put", ], }, + HelpCase { + args: &["bucket", "event", "remove"], + usage: "Usage: rc bucket event remove [OPTIONS] ", + expected_tokens: &[ + "--force", + "Examples:", + "rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts", + ], + }, HelpCase { args: &["bucket", "cors"], usage: "Usage: rc bucket cors [OPTIONS] ", expected_tokens: &["list", "set", "remove"], }, + HelpCase { + args: &["bucket", "cors", "set"], + usage: "Usage: rc bucket cors set [OPTIONS] [SOURCE]", + expected_tokens: &["--file", "--force", "read from stdin"], + }, HelpCase { args: &["object", "copy"], usage: "Usage: rc object copy [OPTIONS] ", @@ -497,6 +511,16 @@ fn nested_subcommand_help_contract() { usage: "Usage: rc object show [OPTIONS] ", expected_tokens: &["--enc-key", "--rewind", "--version-id"], }, + HelpCase { + args: &["object", "stat"], + usage: "Usage: rc object stat [OPTIONS] ", + expected_tokens: &["--version-id", "--rewind"], + }, + HelpCase { + args: &["object", "share"], + usage: "Usage: rc object share [OPTIONS] ", + expected_tokens: &["--expire", "--upload", "--content-type"], + }, HelpCase { args: &["admin", "user", "info"], usage: "Usage: rc admin user info [OPTIONS] ", @@ -774,6 +798,15 @@ fn nested_subcommand_help_contract() { "rc event add local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts --event delete", ], }, + HelpCase { + args: &["event", "remove"], + usage: "Usage: rc event remove [OPTIONS] ", + expected_tokens: &[ + "--force", + "Examples:", + "rc event remove local/my-bucket arn:aws:sns:us-east-1:123456789012:alerts", + ], + }, HelpCase { args: &["replicate", "list"], usage: "Usage: rc replicate list [OPTIONS] ",