diff --git a/rust/src/application/client.rs b/rust/src/application/client.rs index 137cda9b..50720b05 100644 --- a/rust/src/application/client.rs +++ b/rust/src/application/client.rs @@ -514,6 +514,33 @@ mod tests { ); } + /// Distinct from `activate_release_surfaces_graphql_errors`: GraphQL + /// reports failures as HTTP 200 with an `errors` array, but the + /// transport itself (auth rejected, gateway down, etc.) can also fail + /// at the HTTP layer with a non-2xx status. That path maps to + /// `ClientError::Http`, not `ClientError::GraphQL`. + #[tokio::test] + async fn query_maps_a_non_2xx_status_to_a_http_client_error() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/apps/app-registry-subgraph"); + then.status(401).body("unauthorized"); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .get_application("test-app") + .await + .expect_err("non-2xx status should surface as an HTTP error"); + + mock.assert_async().await; + assert!( + matches!(err, ClientError::Http { status: 401, ref body } if body.contains("unauthorized")), + "expected Http error variant, got: {err:?}" + ); + } + #[tokio::test] async fn update_application_promotes_to_active() { let server = MockServer::start_async().await; diff --git a/rust/src/application/commands/deploy/extensions.rs b/rust/src/application/commands/deploy/extensions.rs index fa6c9d79..dd226c7d 100644 --- a/rust/src/application/commands/deploy/extensions.rs +++ b/rust/src/application/commands/deploy/extensions.rs @@ -272,6 +272,145 @@ pub(super) async fn deploy_extension( #[cfg(test)] mod tests { + use crate::config::{ + BlocksExtensionConfig, CheckoutExtensionConfig, Config, EmbedExtensionConfig, + ExtensionTarget, ExtensionsConfig, + }; + + /// Minimal `Config` with placeholder values for every field + /// `collect_extensions` doesn't read — only `extensions` varies per test. + fn base_config(extensions: Option) -> Config { + Config { + name: "test-app".to_owned(), + client_id: "00000000-0000-4000-8000-000000000000".to_owned(), + description: None, + version: "1.0.0".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec![], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions, + } + } + + #[test] + fn collect_extensions_returns_empty_when_none_configured() { + assert!(super::collect_extensions(&base_config(None)).is_empty()); + } + + #[test] + fn collect_extensions_maps_embed_extensions() { + let config = base_config(Some(ExtensionsConfig { + embed: vec![EmbedExtensionConfig { + name: "@test/embed-one".to_owned(), + handle: "embed-one".to_owned(), + source: "src/index.ts".to_owned(), + targets: vec![ExtensionTarget { + target: "admin.product.detail".to_owned(), + }], + }], + checkout: vec![], + blocks: None, + })); + + let deploys = super::collect_extensions(&config); + assert_eq!(deploys.len(), 1); + assert_eq!(deploys[0].name, "@test/embed-one"); + assert_eq!(deploys[0].handle, "embed-one"); + assert_eq!(deploys[0].source, "src/index.ts"); + assert_eq!(deploys[0].ext_type, crate::extension::ExtensionType::Embed); + assert_eq!(deploys[0].targets, vec!["admin.product.detail".to_owned()]); + } + + #[test] + fn collect_extensions_maps_checkout_extensions() { + let config = base_config(Some(ExtensionsConfig { + embed: vec![], + checkout: vec![CheckoutExtensionConfig { + name: "@test/checkout-one".to_owned(), + handle: "checkout-one".to_owned(), + source: "src/checkout.ts".to_owned(), + targets: vec![], + }], + blocks: None, + })); + + let deploys = super::collect_extensions(&config); + assert_eq!(deploys.len(), 1); + assert_eq!(deploys[0].name, "@test/checkout-one"); + assert_eq!( + deploys[0].ext_type, + crate::extension::ExtensionType::Checkout + ); + assert!(deploys[0].targets.is_empty()); + } + + #[test] + fn collect_extensions_maps_blocks_extension_with_fixed_name_and_handle() { + let config = base_config(Some(ExtensionsConfig { + embed: vec![], + checkout: vec![], + blocks: Some(BlocksExtensionConfig { + source: "src/blocks.ts".to_owned(), + }), + })); + + let deploys = super::collect_extensions(&config); + assert_eq!(deploys.len(), 1); + // Blocks has no per-extension name/handle in godaddy.toml — these are + // fixed, matching the single implicit "blocks" upload target. + assert_eq!(deploys[0].name, "Blocks"); + assert_eq!(deploys[0].handle, "blocks"); + assert_eq!(deploys[0].source, "src/blocks.ts"); + assert_eq!(deploys[0].ext_type, crate::extension::ExtensionType::Blocks); + assert!(deploys[0].targets.is_empty()); + } + + #[test] + fn collect_extensions_preserves_embed_then_checkout_then_blocks_order() { + let config = base_config(Some(ExtensionsConfig { + embed: vec![EmbedExtensionConfig { + name: "@test/embed".to_owned(), + handle: "embed".to_owned(), + source: "src/embed.ts".to_owned(), + targets: vec![], + }], + checkout: vec![CheckoutExtensionConfig { + name: "@test/checkout".to_owned(), + handle: "checkout".to_owned(), + source: "src/checkout.ts".to_owned(), + targets: vec![], + }], + blocks: Some(BlocksExtensionConfig { + source: "src/blocks.ts".to_owned(), + }), + })); + + let deploys = super::collect_extensions(&config); + let names: Vec<&str> = deploys.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, vec!["@test/embed", "@test/checkout", "Blocks"]); + } + + #[test] + fn upload_completed_event_omits_percent_when_not_final_target() { + let target = Some("admin.product.detail".to_owned()); + + let event = super::upload_completed_event("widget", &target, false, 1, 2); + + assert_eq!( + event, + serde_json::json!({ + "type": "progress", + "name": "extension.upload", + "status": "completed", + "extensionName": "widget", + "target": "admin.product.detail", + }) + ); + } + #[test] fn resolve_upload_targets_by_type() { use crate::extension::ExtensionType; diff --git a/rust/src/extension/security/mod.rs b/rust/src/extension/security/mod.rs index c76aff0e..a86d54e3 100644 --- a/rust/src/extension/security/mod.rs +++ b/rust/src/extension/security/mod.rs @@ -286,4 +286,89 @@ mod tests { sorted.sort_unstable(); assert_eq!(lines, sorted, "findings not sorted by line"); } + + // ----------------------------------------------------------------------- + // Performance regression guard (DEVEX-721 parity with the deleted TS + // security-scan.perf.test.ts). Rust's scanner takes a pre-bundled string + // rather than a directory of source files, so this targets scan_bundle + // directly, with generous, CI-safe bounds — a canary for algorithmic + // regressions (e.g. a new rule pattern with catastrophic backtracking + // under fancy_regex's lookaround engine), not a speed benchmark. + // ----------------------------------------------------------------------- + + /// One safe, moderately complex "module" repeated to build synthetic + /// bundles of a chosen size — mirrors the shape of the TS fixture's + /// generated modules (a class with a constructor, a `fetch` call, and a + /// `Buffer` call) so the benign-but-pattern-adjacent code the scanner + /// has to walk past is representative, not just blank lines. + fn synthetic_module(i: usize) -> String { + format!( + r#" +class Module{i} {{ + constructor() {{ + this.data = new Map(); + }} + async fetchData(url) {{ + return fetch(url); + }} + processBuffer(input) {{ + return Buffer.from(input, "utf-8"); + }} +}} +"# + ) + } + + fn synthetic_bundle(modules: usize) -> String { + (0..modules).map(synthetic_module).collect::() + } + + #[test] + fn scan_bundle_completes_within_a_generous_bound_on_a_realistic_bundle() { + let bundle = synthetic_bundle(200); + let start = std::time::Instant::now(); + let findings = scan_bundle(&bundle, "bundle.mjs"); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_secs() < 5, + "scanning a 200-module bundle took {elapsed:?} — investigate for a \ + catastrophic-backtracking regex regression" + ); + // The synthetic bundle uses fetch()/Buffer.from() only, which trip + // warn-level rules, not any of the SEC101-110 block rules. + assert!(!is_blocked(&findings), "findings: {findings:?}"); + } + + /// Scanning time should grow roughly linearly with input size, not + /// quadratically or worse — a much looser bound than the TS test's <2x + /// (which measured dev-laptop wall-clock) to stay stable on noisy CI + /// runners, while still catching a real algorithmic regression. + #[test] + fn scan_bundle_time_does_not_blow_up_superlinearly_with_input_size() { + let small = synthetic_bundle(50); + let large = synthetic_bundle(400); // 8x the module count + + let start = std::time::Instant::now(); + scan_bundle(&small, "bundle.mjs"); + let small_elapsed = start.elapsed(); + + let start = std::time::Instant::now(); + scan_bundle(&large, "bundle.mjs"); + let large_elapsed = start.elapsed(); + + // If the small run was too fast to measure meaningfully, there's + // nothing informative to compare — skip rather than divide by + // near-zero and produce a flaky ratio. + if small_elapsed.as_micros() < 100 { + return; + } + + let ratio = large_elapsed.as_secs_f64() / small_elapsed.as_secs_f64(); + assert!( + ratio < 40.0, + "8x input size took {ratio:.1}x longer to scan ({small_elapsed:?} -> \ + {large_elapsed:?}) — looks superlinear" + ); + } } diff --git a/rust/src/hosting/nodejs/source/upload.rs b/rust/src/hosting/nodejs/source/upload.rs index 45b772a6..312a4f0a 100644 --- a/rust/src/hosting/nodejs/source/upload.rs +++ b/rust/src/hosting/nodejs/source/upload.rs @@ -15,6 +15,32 @@ struct SourceUploadArgs { file: String, } +/// Ensure the given zip path exists as a file before attempting upload. +fn require_zip_file(file: &str) -> Result<(), crate::error::GddyError> { + if std::path::Path::new(file).is_file() { + return Ok(()); + } + Err(crate::error::GddyError::validation(format!( + "zip file not found: {file}" + ))) +} + +/// Build the "poll status" next action, prefilling `--job-id` when the upload +/// response returned one, otherwise leaving it required for the user to fill in. +fn upload_status_next_action(app_id: String, upload_job_id: &str) -> cli_engine::NextAction { + let job_id_param = if upload_job_id.is_empty() { + NextActionParam::required() + } else { + NextActionParam::value(upload_job_id) + }; + next_action( + "hosting nodejs source status --app-id --job-id ", + "Poll zip upload status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", job_id_param) +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::( @@ -35,35 +61,58 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx, args: SourceUploadArgs| async move { let app_id = args.app_id; let file = args.file; + require_zip_file(&file).map_err(crate::error::GddyError::into_cli_error)?; let path = std::path::Path::new(&file); - if !path.is_file() { - return Err(crate::error::GddyError::validation(format!( - "zip file not found: {file}" - )) - .into_cli_error()); - } let client = make_client(&ctx, &[CODE_WRITE]).await?; let data = client .upload_source(&app_id, path) .await .map_err(client_err)?; let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); - let action = if upload_job_id.is_empty() { - next_action( - "hosting nodejs source status --app-id --job-id ", - "Poll zip upload status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::required()) - } else { - next_action( - "hosting nodejs source status --app-id --job-id ", - "Poll zip upload status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::value(upload_job_id)) - }; + let action = upload_status_next_action(app_id, upload_job_id); Ok(CommandResult::new(data).with_next_actions(vec![action])) }, ) } + +#[cfg(test)] +mod tests { + use cli_engine::NextActionParam; + + use super::{require_zip_file, upload_status_next_action}; + + #[test] + fn require_zip_file_rejects_missing_path() { + let err = require_zip_file("/no/such/file.zip").expect_err("missing file should error"); + assert!(err.to_string().contains("zip file not found")); + assert!(err.to_string().contains("/no/such/file.zip")); + } + + #[test] + fn require_zip_file_accepts_an_existing_file() { + let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file"); + let path = tmp.path().to_str().expect("temp path should be utf8"); + assert!(require_zip_file(path).is_ok()); + } + + #[test] + fn require_zip_file_rejects_a_directory() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let path = tmp.path().to_str().expect("temp path should be utf8"); + let err = require_zip_file(path).expect_err("directory should error"); + assert!(err.to_string().contains("zip file not found")); + } + + #[test] + fn upload_status_next_action_requires_job_id_when_absent() { + let action = upload_status_next_action("app-123".to_owned(), ""); + assert_eq!(action.params["app-id"], NextActionParam::value("app-123")); + assert_eq!(action.params["job-id"], NextActionParam::required()); + } + + #[test] + fn upload_status_next_action_prefills_job_id_when_present() { + let action = upload_status_next_action("app-123".to_owned(), "job-456"); + assert_eq!(action.params["job-id"], NextActionParam::value("job-456")); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index df23c744..40bca59f 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -224,4 +224,101 @@ mod tests { ); } } + + /// Smoke test for the bare-invocation discovery envelope (DEVEX-721): + /// running `gddy` with no subcommand must exit 0 and print a JSON + /// envelope carrying at least a version and some root-level next actions. + #[tokio::test] + async fn root_invocation_returns_a_json_discovery_envelope_with_next_actions() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Ga) + .with_root_next_actions(Arc::new(|| { + vec![crate::next_action::next_action( + "env get", + "Get the current active environment", + )] + })) + .with_modules(super::all_modules()), + ); + + let output = cli.run(["gddy"]).await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + + let parse_err_msg = format!("root output should be JSON: {}", output.rendered); + let payload: serde_json::Value = + serde_json::from_str(&output.rendered).expect(&parse_err_msg); + assert!( + payload["data"]["version"].is_string(), + "root envelope should carry a version: {payload}" + ); + assert!( + payload["next_actions"] + .as_array() + .is_some_and(|actions| !actions.is_empty()), + "root envelope should surface next actions: {payload}" + ); + } + + /// Registry-completeness smoke test (DEVEX-721): every top-level command + /// or group published by `tree` must carry a non-empty name/path/description, + /// so a bad `CommandSpec` (empty description, blank path) is caught in CI + /// rather than surfacing as a broken `gddy tree`/`--help` at runtime. + #[tokio::test] + async fn command_tree_publishes_every_top_level_node_with_name_path_and_description() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Ga) + .with_modules(super::all_modules()), + ); + + let output = cli.run(["gddy", "tree", "--output", "json"]).await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + + let parse_err_msg = format!("tree output should be JSON: {}", output.rendered); + let payload: serde_json::Value = + serde_json::from_str(&output.rendered).expect(&parse_err_msg); + let children_err_msg = format!("tree should publish children: {payload}"); + let children = payload["data"]["children"] + .as_array() + .expect(&children_err_msg); + assert!(!children.is_empty(), "tree should publish top-level nodes"); + + let names: Vec<&str> = children + .iter() + .filter_map(|node| node["name"].as_str()) + .collect(); + for expected in [ + "api", + "domain", + "dns", + "env", + "pat", + "payment-methods", + "tree", + ] { + assert!( + names.contains(&expected), + "tree should publish {expected:?} among top-level nodes: {names:?}" + ); + } + + for node in children { + for field in ["name", "path", "description"] { + assert!( + node[field].as_str().is_some_and(|s| !s.is_empty()), + "every top-level node should have a non-empty {field:?}: {node}" + ); + } + } + } + + // `--env` actually re-routing command execution to the targeted + // environment (DEVEX-721's `cli-smoke` env-override parity item) is + // already covered end-to-end per-command — see + // `api_explorer::operation::tests::operation_get_full_path_is_hostless_in_a_non_prod_env` + // — since `env get`/`env info` intentionally read the *persisted* + // `.gdenv` environment rather than the per-invocation `--env` override + // (see the doc comment on `env set` above), so a generic root-level test + // here would exercise the wrong command. } diff --git a/rust/src/webhook/mod.rs b/rust/src/webhook/mod.rs index 361a06f5..4eab7887 100644 --- a/rust/src/webhook/mod.rs +++ b/rust/src/webhook/mod.rs @@ -55,6 +55,43 @@ fn write_full_output(events: &[WebhookEvent]) -> Result Ok(path.to_string_lossy().into_owned()) } +/// Fetches and parses the webhook event-types response. +async fn fetch_webhook_events( + client: &reqwest::Client, + base_url: &str, + token: &str, +) -> cli_engine::Result { + let url = format!("{base_url}/v1/apis/webhook-event-types"); + let request = client + .get(&url) + .bearer_auth(token) + .header("x-request-id", uuid::Uuid::new_v4().to_string()) + .build() + .map_err(|e| GddyError::validation(e.to_string()).into_cli_error())?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = client + .execute(request) + .await + .map_err(|e| GddyError::network(e.to_string()).into_cli_error())?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp + .bytes() + .await + .map_err(|e| GddyError::network(e.to_string()).into_cli_error())?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + if !status.is_success() { + let body = String::from_utf8_lossy(&bytes).into_owned(); + return Err(GddyError::from_http(status.as_u16(), body, "webhooks").into_cli_error()); + } + serde_json::from_slice(&bytes).map_err(|e| { + GddyError::unexpected(format!("failed to parse webhook events response: {e}")) + .into_cli_error() + }) +} + /// The webhook command group, composed below `gddy platform`. pub fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( @@ -78,38 +115,8 @@ pub fn group() -> RuntimeGroupSpec { |ctx| async move { let token = ctx.credential().await?.token; let base_url = api_url_for_env(&ctx.middleware.env)?; - let url = format!("{base_url}/v1/apis/webhook-event-types"); let client = crate::application::client::make_http_client(); - let request = client - .get(&url) - .bearer_auth(&token) - .header("x-request-id", uuid::Uuid::new_v4().to_string()) - .build() - .map_err(|e| GddyError::validation(e.to_string()).into_cli_error())?; - cli_engine::transport::debug_log_reqwest_request(&request); - let resp = client - .execute(request) - .await - .map_err(|e| GddyError::network(e.to_string()).into_cli_error())?; - - let status = resp.status(); - let headers = resp.headers().clone(); - let bytes = resp - .bytes() - .await - .map_err(|e| GddyError::network(e.to_string()).into_cli_error())?; - cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); - - if !status.is_success() { - let body = String::from_utf8_lossy(&bytes).into_owned(); - return Err( - GddyError::from_http(status.as_u16(), body, "webhooks").into_cli_error() - ); - } - let response: WebhookEventsResponse = serde_json::from_slice(&bytes).map_err(|e| { - GddyError::unexpected(format!("failed to parse webhook events response: {e}")) - .into_cli_error() - })?; + let response = fetch_webhook_events(&client, &base_url, &token).await?; let events: Vec = response.events; let total = events.len(); let truncated = total > MAX_LIST_ITEMS; @@ -145,8 +152,78 @@ pub fn group() -> RuntimeGroupSpec { #[cfg(test)] mod tests { + use httpmock::prelude::*; + use super::*; + /// `webhook events` calls the platform API, so it must stay fail-closed + /// like every other authenticated command (parity with the deleted TS + /// webhook-service test's "should throw authentication error"/"should + /// throw error with null access token" cases — here the credential gate + /// rejects before the handler ever builds a request). + #[tokio::test] + async fn webhook_events_requires_auth() { + let cli = cli_engine::Cli::new( + cli_engine::CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(cli_engine::Stage::Experimental) + .with_module(crate::platform::module()), + ); + let output = cli + .run(["gddy", "platform", "webhook", "events", "--output", "json"]) + .await; + assert_eq!(output.exit_code, 2, "{}", output.rendered); + } + + // --- fetch_webhook_events: HTTP wiring --- + + #[tokio::test] + async fn fetch_webhook_events_sends_bearer_token_and_parses_response() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/apis/webhook-event-types") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(serde_json::json!({ + "events": [ + { "eventType": "application.created", "description": "created" }, + ] + })); + }) + .await; + + let client = crate::application::client::make_http_client(); + let response = fetch_webhook_events(&client, &server.base_url(), "test-token") + .await + .expect("should fetch events"); + + mock.assert_async().await; + assert_eq!(response.events.len(), 1); + assert_eq!(response.events[0].event_type, "application.created"); + } + + #[tokio::test] + async fn fetch_webhook_events_maps_a_non_2xx_status_to_a_cli_error() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/apis/webhook-event-types"); + then.status(401).body("invalid token"); + }) + .await; + + let client = crate::application::client::make_http_client(); + let err = fetch_webhook_events(&client, &server.base_url(), "bad-token") + .await + .expect_err("non-2xx status should surface as an error"); + + mock.assert_async().await; + assert!( + err.to_string().contains("invalid token"), + "expected the upstream body in the error: {err}" + ); + } + // --- Deserialization --- #[test]