diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 891166ad..1aa83d3b 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +mod native_extension; mod redirect_uris; mod settings; pub(crate) mod settings_form; @@ -30,6 +31,8 @@ pub struct Config { pub extensions: Option, #[serde(default)] pub settings: Vec, + #[serde(default)] + pub native_extension: Option, } impl Config { @@ -128,6 +131,15 @@ impl Config { } } + if let Some(native) = &self.native_extension { + native_extension::validate( + &mut errors, + "native_extension", + &native.support_contact, + &native.android_package_name, + ); + } + settings::validate_settings(&self.settings, &mut errors); if errors.is_empty() { @@ -359,6 +371,14 @@ pub struct ExtensionTarget { pub target: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeExtensionConfig { + #[serde(default)] + pub name: Option, + pub support_contact: String, + pub android_package_name: String, +} + #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("config file not found at {path}")] @@ -488,487 +508,4 @@ pub fn write_env_file( } #[cfg(test)] -mod tests { - use super::settings_form::{ - SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation, - SettingsFormV1Section, SettingsLinkV1Presentation, - }; - use super::*; - - fn valid_config() -> Config { - Config { - name: "my-app".to_owned(), - client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), - description: Some("test".to_owned()), - version: "1.2.3".to_owned(), - url: "https://example.com".to_owned(), - proxy_url: "https://proxy.example.com".to_owned(), - authorization_scopes: vec!["openid".to_owned()], - redirect_uris: None, - actions: vec![], - subscriptions: None, - dependencies: vec![], - extensions: None, - settings: vec![], - } - } - - #[test] - fn relativize_webhook_url_reduces_same_host_url_to_a_path() { - assert_eq!( - relativize_webhook_url( - "https://proxy.example.com/webhooks/orders", - "https://proxy.example.com" - ), - "/webhooks/orders" - ); - } - - #[test] - fn relativize_webhook_url_keeps_query_and_fragment() { - assert_eq!( - relativize_webhook_url( - "https://proxy.example.com/webhooks/orders?x=1#frag", - "https://proxy.example.com" - ), - "/webhooks/orders?x=1#frag" - ); - } - - #[test] - fn relativize_webhook_url_leaves_cross_host_url_unchanged() { - assert_eq!( - relativize_webhook_url( - "https://elsewhere.example.com/webhooks/orders", - "https://proxy.example.com" - ), - "https://elsewhere.example.com/webhooks/orders" - ); - } - - #[test] - fn relativize_webhook_url_leaves_unparsable_url_unchanged() { - assert_eq!( - relativize_webhook_url("not a url", "https://proxy.example.com"), - "not a url" - ); - } - - #[test] - fn env_path_matches_convention() { - use std::path::Path; - assert_eq!(env_path(None), Path::new(".env")); - assert_eq!(env_path(Some("prod")), Path::new(".env")); - assert_eq!(env_path(Some("ote")), Path::new(".env.ote")); - } - - #[test] - fn merge_env_content_writes_fresh_keys() { - let result = merge_env_content(None, "secret", "pubkey", "cid", "csecret"); - assert!(result.contains(r#"GODADDY_WEBHOOK_SECRET="secret""#)); - assert!(result.contains(r#"GODADDY_PUBLIC_KEY="pubkey""#)); - assert!(result.contains(r#"GODADDY_CLIENT_ID="cid""#)); - assert!(result.contains(r#"GODADDY_CLIENT_SECRET="csecret""#)); - } - - #[test] - fn merge_env_content_preserves_existing() { - let existing = "FOO=bar\n# note\nGODADDY_CLIENT_ID=\"old\""; - let result = merge_env_content(Some(existing), "secret", "pubkey", "new_cid", "csecret"); - assert!(result.contains("FOO=bar")); - assert!(result.contains("# note")); - assert!(result.contains(r#"GODADDY_CLIENT_ID="new_cid""#)); - assert!(!result.contains("\"old\"")); - } - - #[test] - fn merge_env_content_dedupes_owned_keys() { - let existing = "GODADDY_CLIENT_ID=a\nGODADDY_CLIENT_ID=b"; - let result = merge_env_content(Some(existing), "s", "p", "new", "cs"); - assert_eq!(result.matches("GODADDY_CLIENT_ID=").count(), 1); - assert!(result.contains(r#"GODADDY_CLIENT_ID="new""#)); - } - - #[test] - fn validate_accepts_a_well_formed_config() { - valid_config().validate().expect("valid config should pass"); - } - - #[test] - fn validate_rejects_invalid_name() { - let mut config = valid_config(); - config.name = "AB".to_owned(); - let err = config.validate().expect_err("uppercase/short name"); - assert!(err.to_string().contains("name must match"), "{err}"); - } - - #[test] - fn is_valid_app_name_pattern() { - assert!(is_valid_app_name("my-app")); - assert!(is_valid_app_name("abc")); - assert!(is_valid_app_name(&"a".repeat(255))); - assert!(!is_valid_app_name("")); - assert!(!is_valid_app_name("ab")); - assert!(!is_valid_app_name("AB")); - assert!(!is_valid_app_name("MyApp")); - assert!(!is_valid_app_name("my_app")); - assert!(!is_valid_app_name(&"a".repeat(256))); - } - - #[test] - fn validate_rejects_non_v4_uuid_client_id() { - let mut config = valid_config(); - config.client_id = "550e8400-e29b-11d4-a716-446655440000".to_owned(); - let err = config.validate().expect_err("uuid v1"); - assert!(err.to_string().contains("client_id"), "{err}"); - } - - #[test] - fn validate_rejects_non_semver_version() { - let mut config = valid_config(); - config.version = "1.0".to_owned(); - let err = config.validate().expect_err("incomplete semver"); - assert!(err.to_string().contains("version"), "{err}"); - } - - #[test] - fn validate_rejects_non_absolute_urls() { - let mut config = valid_config(); - config.url = "/relative".to_owned(); - let err = config.validate().expect_err("relative url"); - assert!( - err.to_string() - .contains("url must be an absolute http(s) URL"), - "{err}" - ); - } - - #[test] - fn validate_rejects_non_http_url_schemes() { - let mut config = valid_config(); - config.url = "ftp://files.example.com/app".to_owned(); - config.proxy_url = "file:///tmp/proxy".to_owned(); - let err = config.validate().expect_err("non-http schemes"); - let msg = err.to_string(); - assert!(msg.contains("url must be an absolute http(s) URL"), "{msg}"); - assert!( - msg.contains("proxy_url must be an absolute http(s) URL"), - "{msg}" - ); - } - - #[test] - fn validate_rejects_non_http_endpoint_scheme() { - let mut config = valid_config(); - config.actions.push(ActionConfig { - name: "sync".to_owned(), - url: "ftp://files.example.com/sync".to_owned(), - }); - let err = config.validate().expect_err("ftp action endpoint"); - assert!(err.to_string().contains("actions[0].url"), "{err}"); - } - - #[test] - fn validate_rejects_uuid_with_non_rfc4122_variant() { - let mut config = valid_config(); - config.client_id = "550e8400-e29b-41d4-c716-446655440000".to_owned(); - let err = config.validate().expect_err("bad uuid variant"); - assert!(err.to_string().contains("client_id"), "{err}"); - } - - #[test] - fn validate_rejects_empty_authorization_scopes() { - let mut config = valid_config(); - config.authorization_scopes.clear(); - let err = config.validate().expect_err("empty scopes"); - assert!(err.to_string().contains("authorization_scopes"), "{err}"); - } - - #[test] - fn validate_rejects_short_action_name_and_bad_endpoint() { - let mut config = valid_config(); - config.actions.push(ActionConfig { - name: "ab".to_owned(), - url: "https://not a url".to_owned(), - }); - let err = config.validate().expect_err("bad action"); - let msg = err.to_string(); - assert!(msg.contains("actions[0].name"), "{msg}"); - assert!(msg.contains("actions[0].url"), "{msg}"); - } - - #[test] - fn validate_accepts_proxy_relative_action_url() { - let mut config = valid_config(); - config.actions.push(ActionConfig { - name: "sync".to_owned(), - url: "/actions/sync".to_owned(), - }); - config.validate().expect("proxy-relative action url"); - } - - #[test] - fn validate_rejects_subscription_without_events() { - let mut config = valid_config(); - config.subscriptions = Some(SubscriptionsConfig { - webhook: vec![SubscriptionConfig { - name: "hook".to_owned(), - events: vec![], - url: "/hooks".to_owned(), - }], - }); - let err = config.validate().expect_err("empty events"); - assert!(err.to_string().contains("events"), "{err}"); - } - - #[test] - fn validate_rejects_dependency_with_bad_semver() { - let mut config = valid_config(); - config.dependencies.push(DependenciesConfig { - app: vec![DependencyConfig { - name: "other-app".to_owned(), - version: Some("not-semver".to_owned()), - }], - feature: vec![], - }); - let err = config.validate().expect_err("bad dep version"); - assert!( - err.to_string().contains("dependencies[0].app[0].version"), - "{err}" - ); - } - - #[test] - fn validate_rejects_embed_without_targets() { - let mut config = valid_config(); - config.extensions = Some(ExtensionsConfig { - embed: vec![EmbedExtensionConfig { - name: "panel".to_owned(), - handle: "panel-handle".to_owned(), - source: "ext/index.tsx".to_owned(), - targets: vec![], - }], - checkout: vec![], - blocks: None, - }); - let err = config.validate().expect_err("missing targets"); - assert!(err.to_string().contains("targets"), "{err}"); - } - - #[test] - fn validate_accepts_embed_with_targets() { - let mut config = valid_config(); - config.extensions = Some(ExtensionsConfig { - embed: vec![EmbedExtensionConfig { - name: "panel".to_owned(), - handle: "panel-handle".to_owned(), - source: "ext/index.tsx".to_owned(), - targets: vec![ExtensionTarget { - target: "commerce.product.details".to_owned(), - }], - }], - checkout: vec![], - blocks: None, - }); - config.validate().expect("embed with targets should pass"); - } - - #[test] - fn validate_rejects_empty_blocks_source() { - let mut config = valid_config(); - config.extensions = Some(ExtensionsConfig { - embed: vec![], - checkout: vec![], - blocks: Some(BlocksExtensionConfig { - source: String::new(), - }), - }); - let err = config.validate().expect_err("empty blocks source"); - assert!( - err.to_string().contains("extensions.blocks.source"), - "{err}" - ); - } - - #[test] - fn validate_accepts_placement_only_setting() { - let mut config = valid_config(); - config.settings.push(SettingConfig { - group: "tax-center".to_owned(), - slug: "godaddy-tax".to_owned(), - title: None, - description: None, - entry_path: "/settings/godaddy-tax".to_owned(), - order: None, - capabilities: vec![], - icon: None, - metadata: None, - presentation_file: None, - presentation: None, - }); - config - .validate() - .expect("placement-only setting should be valid"); - } - - #[test] - fn validate_rejects_invalid_setting_slug() { - let mut config = valid_config(); - config.settings.push(SettingConfig { - group: "Tax_Center".to_owned(), - slug: "godaddy-tax".to_owned(), - title: None, - description: None, - entry_path: "/settings/godaddy-tax".to_owned(), - order: None, - capabilities: vec![], - icon: None, - metadata: None, - presentation_file: None, - presentation: None, - }); - let err = config.validate().expect_err("bad group slug"); - assert!(err.to_string().contains("settings[0].group"), "{err}"); - } - - #[test] - fn setting_with_presentation_round_trips_through_toml() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("godaddy.toml"); - let mut config = valid_config(); - config.settings.push(SettingConfig { - group: "tax-center".to_owned(), - slug: "godaddy-tax".to_owned(), - title: Some("GoDaddy Tax".to_owned()), - description: None, - entry_path: "/settings/godaddy-tax".to_owned(), - order: Some(10), - capabilities: vec!["read".to_owned(), "write".to_owned()], - icon: Some(SettingIcon { - name: "percent".to_owned(), - library: "lucide".to_owned(), - }), - metadata: None, - presentation_file: None, - presentation: Some(SettingPresentation::Form(SettingsFormV1Presentation { - sections: vec![SettingsFormV1Section { - key: "defaults".to_owned(), - label: "Defaults".to_owned(), - description: None, - visible_when: None, - fields: vec![SettingsFormV1Field::Boolean { - key: "autoCalculate".to_owned(), - label: "Auto-calculate".to_owned(), - description: None, - required: false, - default_value: Some(true), - }], - }], - })), - }); - write_config(&path, &config).expect("write config with setting"); - let read_back = read_config(&path).expect("read config with setting"); - assert_eq!(read_back.settings.len(), 1); - assert_eq!(read_back.settings[0].entry_path, "/settings/godaddy-tax"); - let SettingPresentation::Form(form) = read_back.settings[0] - .presentation - .as_ref() - .expect("presentation") - else { - unreachable!("expected form presentation"); - }; - let SettingsFormV1Field::Boolean { default_value, .. } = &form.sections[0].fields[0] else { - unreachable!("expected boolean field"); - }; - assert_eq!(default_value, &Some(true)); - } - - #[test] - fn setting_with_config_allowlist_round_trips_through_toml() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("godaddy.toml"); - let mut config = valid_config(); - config.settings.push(SettingConfig { - group: "payment-methods".to_owned(), - slug: "paypal-payments".to_owned(), - title: None, - description: None, - entry_path: "/settings/paypal".to_owned(), - order: None, - capabilities: vec![ - "read".to_owned(), - "open".to_owned(), - "config".to_owned(), - "delete".to_owned(), - ], - icon: None, - metadata: Some(serde_json::json!({ - "configKeys": ["clientId", "merchantId"] - })), - presentation_file: None, - presentation: Some(SettingPresentation::Link(SettingsLinkV1Presentation { - label: "Configure PayPal".to_owned(), - open_mode: "new-window".to_owned(), - })), - }); - - write_config(&path, &config).expect("write config setting"); - let read_back = read_config(&path).expect("read config setting"); - assert_eq!( - read_back.settings[0].metadata, - Some(serde_json::json!({ - "configKeys": ["clientId", "merchantId"] - })) - ); - } - - #[test] - fn setting_with_presentation_file_round_trips_without_expansion() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("godaddy.toml"); - let mut config = valid_config(); - config.settings.push(SettingConfig { - group: "tax-center".to_owned(), - slug: "manual-tax".to_owned(), - title: None, - description: None, - entry_path: "/settings/manual-tax".to_owned(), - order: None, - capabilities: vec![], - icon: None, - metadata: None, - presentation_file: Some("fixtures/manual-tax-presentation.json".to_owned()), - presentation: None, - }); - write_config(&path, &config).expect("write config with presentationFile"); - let read_back = read_config(&path).expect("read config with presentationFile"); - assert_eq!( - read_back.settings[0].presentation_file, - Some("fixtures/manual-tax-presentation.json".to_owned()) - ); - assert!(read_back.settings[0].presentation.is_none()); - } - - #[test] - fn read_config_runs_validation() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("godaddy.toml"); - let mut config = valid_config(); - config.name = "x".to_owned(); - let raw = toml::to_string_pretty(&config).expect("serialize"); - std::fs::write(&path, raw).expect("write"); - let err = read_config(&path).expect_err("short name should fail validation on read"); - assert!(matches!(err, ConfigError::Validation(_)), "got {err:?}"); - } - - #[test] - fn write_config_runs_validation() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("godaddy.toml"); - let mut config = valid_config(); - config.name = "x".to_owned(); - let err = write_config(&path, &config).expect_err("short name should fail on write"); - assert!(matches!(err, ConfigError::Validation(_)), "got {err:?}"); - assert!(!path.exists(), "invalid config must not be written"); - } -} +mod tests; diff --git a/rust/src/config/native_extension.rs b/rust/src/config/native_extension.rs new file mode 100644 index 00000000..c134e966 --- /dev/null +++ b/rust/src/config/native_extension.rs @@ -0,0 +1,96 @@ +//! Validation for `[native_extension]` manifest configuration. + +pub(super) fn validate( + errors: &mut Vec, + path: &str, + support_contact: &str, + android_package_name: &str, +) { + if !is_valid_email(support_contact) { + errors.push(format!( + "{path}.support_contact must be a valid email address (got {support_contact:?})" + )); + } + if android_package_name.is_empty() { + errors.push(format!("{path}.android_package_name must be non-empty")); + } +} + +/// Match the email shape enforced by DevX Core's Zod v3 `email()` validator. +fn is_valid_email(value: &str) -> bool { + if value.starts_with('.') || value.contains("..") { + return false; + } + let Some((local, domain)) = value.split_once('@') else { + return false; + }; + if domain.contains('@') || local.is_empty() { + return false; + } + let Some(last_local) = local.bytes().next_back() else { + return false; + }; + if !local.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'\'' | b'+' | b'-' | b'.') + }) || !(last_local.is_ascii_alphanumeric() || matches!(last_local, b'_' | b'+' | b'-')) + { + return false; + } + + let mut labels = domain.split('.').peekable(); + let Some(first_label) = labels.next() else { + return false; + }; + if labels.peek().is_none() || !is_valid_domain_label(first_label) { + return false; + } + let remaining: Vec<&str> = labels.collect(); + let Some(top_level_domain) = remaining.last() else { + return false; + }; + remaining.iter().all(|label| is_valid_domain_label(label)) + && top_level_domain.len() >= 2 + && top_level_domain + .bytes() + .all(|byte| byte.is_ascii_alphabetic()) +} + +fn is_valid_domain_label(label: &str) -> bool { + label + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +#[cfg(test)] +mod tests { + use super::is_valid_email; + + #[test] + fn rejects_malformed_addresses() { + for value in [ + "", + "not-an-email", + ".support@example.com", + "support..team@example.com", + "support@example", + "support@example.c", + ] { + assert!(!is_valid_email(value), "unexpected valid email: {value:?}"); + } + } + + #[test] + fn accepts_devx_core_email_shape() { + for value in [ + "support@example.com", + "native.app+alerts@sub.example.co.uk", + "team_member@example.io", + ] { + assert!(is_valid_email(value), "unexpected invalid email: {value:?}"); + } + } +} diff --git a/rust/src/config/redirect_uris.rs b/rust/src/config/redirect_uris.rs index 35538915..10256e78 100644 --- a/rust/src/config/redirect_uris.rs +++ b/rust/src/config/redirect_uris.rs @@ -82,6 +82,7 @@ mod tests { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, } } diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs index 21b7108b..e84a09e2 100644 --- a/rust/src/config/settings_form.rs +++ b/rust/src/config/settings_form.rs @@ -369,10 +369,10 @@ fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: & } match field { SettingsFormV1Field::Select { options, .. } - | SettingsFormV1Field::MultiSelect { options, .. } => { - if options.is_empty() { - errors.push(format!("{path}.options must contain at least one option")); - } + | SettingsFormV1Field::MultiSelect { options, .. } + if options.is_empty() => + { + errors.push(format!("{path}.options must contain at least one option")); } SettingsFormV1Field::ListGroup { item, .. } => { if !is_field_name(&item.id_field) { diff --git a/rust/src/config/tests.rs b/rust/src/config/tests.rs new file mode 100644 index 00000000..6ab8c843 --- /dev/null +++ b/rust/src/config/tests.rs @@ -0,0 +1,602 @@ +use super::settings_form::{ + SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section, + SettingsLinkV1Presentation, +}; +use super::*; + +fn valid_config() -> Config { + Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + redirect_uris: None, + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } +} + +#[test] +fn relativize_webhook_url_reduces_same_host_url_to_a_path() { + assert_eq!( + relativize_webhook_url( + "https://proxy.example.com/webhooks/orders", + "https://proxy.example.com" + ), + "/webhooks/orders" + ); +} + +#[test] +fn relativize_webhook_url_keeps_query_and_fragment() { + assert_eq!( + relativize_webhook_url( + "https://proxy.example.com/webhooks/orders?x=1#frag", + "https://proxy.example.com" + ), + "/webhooks/orders?x=1#frag" + ); +} + +#[test] +fn relativize_webhook_url_leaves_cross_host_url_unchanged() { + assert_eq!( + relativize_webhook_url( + "https://elsewhere.example.com/webhooks/orders", + "https://proxy.example.com" + ), + "https://elsewhere.example.com/webhooks/orders" + ); +} + +#[test] +fn relativize_webhook_url_leaves_unparsable_url_unchanged() { + assert_eq!( + relativize_webhook_url("not a url", "https://proxy.example.com"), + "not a url" + ); +} + +#[test] +fn env_path_matches_convention() { + use std::path::Path; + assert_eq!(env_path(None), Path::new(".env")); + assert_eq!(env_path(Some("prod")), Path::new(".env")); + assert_eq!(env_path(Some("ote")), Path::new(".env.ote")); +} + +#[test] +fn merge_env_content_writes_fresh_keys() { + let result = merge_env_content(None, "secret", "pubkey", "cid", "csecret"); + assert!(result.contains(r#"GODADDY_WEBHOOK_SECRET="secret""#)); + assert!(result.contains(r#"GODADDY_PUBLIC_KEY="pubkey""#)); + assert!(result.contains(r#"GODADDY_CLIENT_ID="cid""#)); + assert!(result.contains(r#"GODADDY_CLIENT_SECRET="csecret""#)); +} + +#[test] +fn merge_env_content_preserves_existing() { + let existing = "FOO=bar\n# note\nGODADDY_CLIENT_ID=\"old\""; + let result = merge_env_content(Some(existing), "secret", "pubkey", "new_cid", "csecret"); + assert!(result.contains("FOO=bar")); + assert!(result.contains("# note")); + assert!(result.contains(r#"GODADDY_CLIENT_ID="new_cid""#)); + assert!(!result.contains("\"old\"")); +} + +#[test] +fn merge_env_content_dedupes_owned_keys() { + let existing = "GODADDY_CLIENT_ID=a\nGODADDY_CLIENT_ID=b"; + let result = merge_env_content(Some(existing), "s", "p", "new", "cs"); + assert_eq!(result.matches("GODADDY_CLIENT_ID=").count(), 1); + assert!(result.contains(r#"GODADDY_CLIENT_ID="new""#)); +} + +#[test] +fn validate_accepts_a_well_formed_config() { + valid_config().validate().expect("valid config should pass"); +} + +fn native_extension( + name: Option<&str>, + support_contact: &str, + android_package_name: &str, +) -> NativeExtensionConfig { + NativeExtensionConfig { + name: name.map(str::to_owned), + support_contact: support_contact.to_owned(), + android_package_name: android_package_name.to_owned(), + } +} + +#[test] +fn validate_accepts_native_extension_with_optional_name_omitted() { + let mut config = valid_config(); + config.native_extension = Some(native_extension( + None, + "support@example.com", + "com.example.app", + )); + config + .validate() + .expect("native_extension with no name should pass"); +} + +#[test] +fn validate_rejects_empty_native_extension_support_contact() { + let mut config = valid_config(); + config.native_extension = Some(native_extension(None, "", "com.example.app")); + let err = config + .validate() + .expect_err("empty support_contact must fail"); + assert!( + err.to_string().contains("native_extension.support_contact"), + "{err}" + ); +} + +#[test] +fn validate_rejects_empty_native_extension_android_package_name() { + let mut config = valid_config(); + config.native_extension = Some(native_extension(None, "support@example.com", "")); + let err = config + .validate() + .expect_err("empty android_package_name must fail"); + assert!( + err.to_string() + .contains("native_extension.android_package_name"), + "{err}" + ); +} + +#[test] +fn native_extension_name_omitted_deserializes_as_none() { + let raw = r#" +name = "my-app" +client_id = "550e8400-e29b-41d4-a716-446655440000" +version = "1.2.3" +url = "https://example.com" +proxy_url = "https://proxy.example.com" +authorization_scopes = ["openid"] + +[native_extension] +support_contact = "support@example.com" +android_package_name = "com.example.app" +"#; + let config: Config = toml::from_str(raw).expect("parse"); + config.validate().expect("validate"); + let native = config.native_extension.expect("section present"); + assert_eq!(native.name, None); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); +} + +#[test] +fn native_extension_name_present_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.native_extension = Some(native_extension( + Some("My Display Name"), + "support@example.com", + "com.example.app", + )); + write_config(&path, &config).expect("write"); + let read_back = read_config(&path).expect("read"); + let native = read_back.native_extension.expect("section present"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); +} + +#[test] +fn read_config_rejects_native_extension_missing_support_contact() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + std::fs::write( + &path, + r#" +name = "my-app" +client_id = "550e8400-e29b-41d4-a716-446655440000" +version = "1.2.3" +url = "https://example.com" +proxy_url = "https://proxy.example.com" +authorization_scopes = ["openid"] + +[native_extension] +android_package_name = "com.example.app" +"#, + ) + .expect("write"); + let err = read_config(&path) + .expect_err("missing support_contact must fail at parse, not the network"); + assert!( + matches!(err, ConfigError::Parse(_)), + "expected parse error, got {err:?}" + ); +} + +#[test] +fn validate_rejects_invalid_name() { + let mut config = valid_config(); + config.name = "AB".to_owned(); + let err = config.validate().expect_err("uppercase/short name"); + assert!(err.to_string().contains("name must match"), "{err}"); +} + +#[test] +fn is_valid_app_name_pattern() { + assert!(is_valid_app_name("my-app")); + assert!(is_valid_app_name("abc")); + assert!(is_valid_app_name(&"a".repeat(255))); + assert!(!is_valid_app_name("")); + assert!(!is_valid_app_name("ab")); + assert!(!is_valid_app_name("AB")); + assert!(!is_valid_app_name("MyApp")); + assert!(!is_valid_app_name("my_app")); + assert!(!is_valid_app_name(&"a".repeat(256))); +} + +#[test] +fn validate_rejects_non_v4_uuid_client_id() { + let mut config = valid_config(); + config.client_id = "550e8400-e29b-11d4-a716-446655440000".to_owned(); + let err = config.validate().expect_err("uuid v1"); + assert!(err.to_string().contains("client_id"), "{err}"); +} + +#[test] +fn validate_rejects_non_semver_version() { + let mut config = valid_config(); + config.version = "1.0".to_owned(); + let err = config.validate().expect_err("incomplete semver"); + assert!(err.to_string().contains("version"), "{err}"); +} + +#[test] +fn validate_rejects_non_absolute_urls() { + let mut config = valid_config(); + config.url = "/relative".to_owned(); + let err = config.validate().expect_err("relative url"); + assert!( + err.to_string() + .contains("url must be an absolute http(s) URL"), + "{err}" + ); +} + +#[test] +fn validate_rejects_non_http_url_schemes() { + let mut config = valid_config(); + config.url = "ftp://files.example.com/app".to_owned(); + config.proxy_url = "file:///tmp/proxy".to_owned(); + let err = config.validate().expect_err("non-http schemes"); + let msg = err.to_string(); + assert!(msg.contains("url must be an absolute http(s) URL"), "{msg}"); + assert!( + msg.contains("proxy_url must be an absolute http(s) URL"), + "{msg}" + ); +} + +#[test] +fn validate_rejects_non_http_endpoint_scheme() { + let mut config = valid_config(); + config.actions.push(ActionConfig { + name: "sync".to_owned(), + url: "ftp://files.example.com/sync".to_owned(), + }); + let err = config.validate().expect_err("ftp action endpoint"); + assert!(err.to_string().contains("actions[0].url"), "{err}"); +} + +#[test] +fn validate_rejects_uuid_with_non_rfc4122_variant() { + let mut config = valid_config(); + config.client_id = "550e8400-e29b-41d4-c716-446655440000".to_owned(); + let err = config.validate().expect_err("bad uuid variant"); + assert!(err.to_string().contains("client_id"), "{err}"); +} + +#[test] +fn validate_rejects_empty_authorization_scopes() { + let mut config = valid_config(); + config.authorization_scopes.clear(); + let err = config.validate().expect_err("empty scopes"); + assert!(err.to_string().contains("authorization_scopes"), "{err}"); +} + +#[test] +fn validate_rejects_short_action_name_and_bad_endpoint() { + let mut config = valid_config(); + config.actions.push(ActionConfig { + name: "ab".to_owned(), + url: "https://not a url".to_owned(), + }); + let err = config.validate().expect_err("bad action"); + let msg = err.to_string(); + assert!(msg.contains("actions[0].name"), "{msg}"); + assert!(msg.contains("actions[0].url"), "{msg}"); +} + +#[test] +fn validate_accepts_proxy_relative_action_url() { + let mut config = valid_config(); + config.actions.push(ActionConfig { + name: "sync".to_owned(), + url: "/actions/sync".to_owned(), + }); + config.validate().expect("proxy-relative action url"); +} + +#[test] +fn validate_rejects_subscription_without_events() { + let mut config = valid_config(); + config.subscriptions = Some(SubscriptionsConfig { + webhook: vec![SubscriptionConfig { + name: "hook".to_owned(), + events: vec![], + url: "/hooks".to_owned(), + }], + }); + let err = config.validate().expect_err("empty events"); + assert!(err.to_string().contains("events"), "{err}"); +} + +#[test] +fn validate_rejects_dependency_with_bad_semver() { + let mut config = valid_config(); + config.dependencies.push(DependenciesConfig { + app: vec![DependencyConfig { + name: "other-app".to_owned(), + version: Some("not-semver".to_owned()), + }], + feature: vec![], + }); + let err = config.validate().expect_err("bad dep version"); + assert!( + err.to_string().contains("dependencies[0].app[0].version"), + "{err}" + ); +} + +#[test] +fn validate_rejects_embed_without_targets() { + let mut config = valid_config(); + config.extensions = Some(ExtensionsConfig { + embed: vec![EmbedExtensionConfig { + name: "panel".to_owned(), + handle: "panel-handle".to_owned(), + source: "ext/index.tsx".to_owned(), + targets: vec![], + }], + checkout: vec![], + blocks: None, + }); + let err = config.validate().expect_err("missing targets"); + assert!(err.to_string().contains("targets"), "{err}"); +} + +#[test] +fn validate_accepts_embed_with_targets() { + let mut config = valid_config(); + config.extensions = Some(ExtensionsConfig { + embed: vec![EmbedExtensionConfig { + name: "panel".to_owned(), + handle: "panel-handle".to_owned(), + source: "ext/index.tsx".to_owned(), + targets: vec![ExtensionTarget { + target: "commerce.product.details".to_owned(), + }], + }], + checkout: vec![], + blocks: None, + }); + config.validate().expect("embed with targets should pass"); +} + +#[test] +fn validate_rejects_empty_blocks_source() { + let mut config = valid_config(); + config.extensions = Some(ExtensionsConfig { + embed: vec![], + checkout: vec![], + blocks: Some(BlocksExtensionConfig { + source: String::new(), + }), + }); + let err = config.validate().expect_err("empty blocks source"); + assert!( + err.to_string().contains("extensions.blocks.source"), + "{err}" + ); +} + +#[test] +fn validate_accepts_placement_only_setting() { + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + }); + config + .validate() + .expect("placement-only setting should be valid"); +} + +#[test] +fn validate_rejects_invalid_setting_slug() { + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "Tax_Center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + }); + let err = config.validate().expect_err("bad group slug"); + assert!(err.to_string().contains("settings[0].group"), "{err}"); +} + +#[test] +fn setting_with_presentation_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: Some("GoDaddy Tax".to_owned()), + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: Some(10), + capabilities: vec!["read".to_owned(), "write".to_owned()], + icon: Some(SettingIcon { + name: "percent".to_owned(), + library: "lucide".to_owned(), + }), + metadata: None, + presentation_file: None, + presentation: Some(SettingPresentation::Form(SettingsFormV1Presentation { + sections: vec![SettingsFormV1Section { + key: "defaults".to_owned(), + label: "Defaults".to_owned(), + description: None, + visible_when: None, + fields: vec![SettingsFormV1Field::Boolean { + key: "autoCalculate".to_owned(), + label: "Auto-calculate".to_owned(), + description: None, + required: false, + default_value: Some(true), + }], + }], + })), + }); + write_config(&path, &config).expect("write config with setting"); + let read_back = read_config(&path).expect("read config with setting"); + assert_eq!(read_back.settings.len(), 1); + assert_eq!(read_back.settings[0].entry_path, "/settings/godaddy-tax"); + let SettingPresentation::Form(form) = read_back.settings[0] + .presentation + .as_ref() + .expect("presentation") + else { + unreachable!("expected form presentation"); + }; + let SettingsFormV1Field::Boolean { default_value, .. } = &form.sections[0].fields[0] else { + unreachable!("expected boolean field"); + }; + assert_eq!(default_value, &Some(true)); +} + +#[test] +fn setting_with_config_allowlist_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "payment-methods".to_owned(), + slug: "paypal-payments".to_owned(), + title: None, + description: None, + entry_path: "/settings/paypal".to_owned(), + order: None, + capabilities: vec![ + "read".to_owned(), + "open".to_owned(), + "config".to_owned(), + "delete".to_owned(), + ], + icon: None, + metadata: Some(serde_json::json!({ + "configKeys": ["clientId", "merchantId"] + })), + presentation_file: None, + presentation: Some(SettingPresentation::Link(SettingsLinkV1Presentation { + label: "Configure PayPal".to_owned(), + open_mode: "new-window".to_owned(), + })), + }); + + write_config(&path, &config).expect("write config setting"); + let read_back = read_config(&path).expect("read config setting"); + assert_eq!( + read_back.settings[0].metadata, + Some(serde_json::json!({ + "configKeys": ["clientId", "merchantId"] + })) + ); +} + +#[test] +fn setting_with_presentation_file_round_trips_without_expansion() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "manual-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/manual-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: Some("fixtures/manual-tax-presentation.json".to_owned()), + presentation: None, + }); + write_config(&path, &config).expect("write config with presentationFile"); + let read_back = read_config(&path).expect("read config with presentationFile"); + assert_eq!( + read_back.settings[0].presentation_file, + Some("fixtures/manual-tax-presentation.json".to_owned()) + ); + assert!(read_back.settings[0].presentation.is_none()); +} + +#[test] +fn read_config_runs_validation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.name = "x".to_owned(); + let raw = toml::to_string_pretty(&config).expect("serialize"); + std::fs::write(&path, raw).expect("write"); + let err = read_config(&path).expect_err("short name should fail validation on read"); + assert!(matches!(err, ConfigError::Validation(_)), "got {err:?}"); +} + +#[test] +fn write_config_runs_validation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.name = "x".to_owned(); + let err = write_config(&path, &config).expect_err("short name should fail on write"); + assert!(matches!(err, ConfigError::Validation(_)), "got {err:?}"); + assert!(!path.exists(), "invalid config must not be written"); +} diff --git a/rust/src/main.rs b/rust/src/main.rs index e7187b34..0525e4bb 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -420,6 +420,93 @@ mod tests { } } + #[tokio::test] + async fn native_extension_is_hidden_at_ga_and_visible_at_experimental() { + let hidden = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Ga) + .with_module(super::platform::module()), + ); + let output = hidden + .run([ + "gddy", + "platform", + "app", + "add", + "native-extension", + "--help", + ]) + .await; + assert_ne!( + output.exit_code, 0, + "native-extension should stay hidden at the Ga default: {}", + output.rendered + ); + + let add_help = hidden + .run(["gddy", "platform", "app", "add", "--help"]) + .await; + assert_eq!(add_help.exit_code, 0, "{}", add_help.rendered); + assert!( + !add_help.rendered.contains("native-extension"), + "add help should not list native-extension at Ga: {}", + add_help.rendered + ); + + let release_help = hidden + .run(["gddy", "platform", "app", "release", "--help"]) + .await; + assert_eq!(release_help.exit_code, 0, "{}", release_help.rendered); + + let revealed = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Experimental) + .with_module(super::platform::module()), + ); + let output = revealed + .run([ + "gddy", + "platform", + "app", + "add", + "native-extension", + "--help", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("--support-contact"), + "missing --support-contact: {}", + output.rendered + ); + assert!( + output.rendered.contains("--android-package-name"), + "missing --android-package-name: {}", + output.rendered + ); + } + + #[tokio::test] + async fn platform_app_add_help_omits_native_extension_copy_at_ga() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Ga) + .with_module(super::platform::module()), + ); + let output = cli.run(["gddy", "platform", "app", "add", "--help"]).await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + assert!( + !output.rendered.contains("native extension"), + "add help still names a native extension: {}", + output.rendered + ); + assert!( + !output.rendered.contains("DevX Core"), + "add help still names DevX Core: {}", + output.rendered + ); + } + // `--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 diff --git a/rust/src/platform/app/client.rs b/rust/src/platform/app/client.rs index 67e6f6ec..d143792d 100644 --- a/rust/src/platform/app/client.rs +++ b/rust/src/platform/app/client.rs @@ -181,7 +181,7 @@ impl ApplicationClient { pub async fn create_release(&self, input: Value) -> Result { self.query(json!({ - "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", + "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } nativeExtensions { id name packageName contact platform } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", "variables": { "input": input } })) .await @@ -588,6 +588,64 @@ mod tests { ); } + #[tokio::test] + async fn create_release_sends_native_extensions_input_and_selects_them() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|req| { + let body = req.body_string(); + body.contains("CreateRelease") + && body.contains( + "nativeExtensions { id name packageName contact platform }", + ) + && body.contains(r#""platform":"ANDROID""#) + && body.contains(r#""packageName":"com.example.app""#) + }); + then.status(200).json_body(json!({ + "data": { + "createRelease": { + "id": "rel-1", + "version": "1.0.11", + "nativeExtensions": [{ + "id": "ne-1", + "name": "My Display Name", + "packageName": "com.example.app", + "contact": "support@example.com", + "platform": "ANDROID" + }] + } + } + })); + }) + .await; + + let input = json!({ + "applicationId": "app-123", + "version": "1.0.11", + "nativeExtensions": [{ + "platform": "ANDROID", + "name": "My Display Name", + "contact": "support@example.com", + "packageName": "com.example.app" + }] + }); + let data = ApplicationClient::new(server.base_url(), "test-token") + .create_release(input) + .await + .expect("create release"); + + mock.assert_async().await; + assert_eq!(data["createRelease"]["nativeExtensions"][0]["id"], "ne-1"); + assert_eq!( + data["createRelease"]["nativeExtensions"][0]["packageName"], + "com.example.app" + ); + } + #[tokio::test] async fn activate_release_surfaces_graphql_errors() { let server = MockServer::start_async().await; diff --git a/rust/src/platform/app/commands/add.rs b/rust/src/platform/app/commands/add.rs index da4807a9..4dbd35a8 100644 --- a/rust/src/platform/app/commands/add.rs +++ b/rust/src/platform/app/commands/add.rs @@ -8,6 +8,8 @@ use serde_json::json; use super::schemas::{ConfigAction, ConfigSetting, ConfigSubscription}; +mod native_extension; + #[derive(Debug, Clone, clap::Args)] struct ActionArgs { /// Unique action name written into godaddy.toml. @@ -88,9 +90,9 @@ struct SubscriptionArgs { pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("add", "Add components to an application").with_long( - "Append actions, webhook subscriptions, or UI extensions to the \ - godaddy.toml manifest in the current directory. Run `gddy platform \ - app deploy` to publish the updated manifest.", + "Add actions, webhook subscriptions, or UI extensions to the \ + godaddy.toml manifest in the current directory. Components are \ + published by a later deploy or release.", ), ) .with_command(RuntimeCommandSpec::new_typed_with_context::< @@ -243,11 +245,382 @@ pub(super) fn group() -> RuntimeGroupSpec { ) }, )) + .with_command(native_extension::command()) .with_group(super::add_extension::group()) } #[cfg(test)] mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + fn test_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + redirect_uris: None, + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + fn native_args(support_contact: &str) -> super::native_extension::NativeExtensionArgs { + super::native_extension::NativeExtensionArgs { + name: Some("My Display Name".to_owned()), + support_contact: support_contact.to_owned(), + android_package_name: "com.example.app".to_owned(), + accept_agreements: false, + } + } + + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-registry-id", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + + #[test] + fn native_extension_subcommand_accepts_required_and_optional_flags() { + super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--name", + "My Display Name", + "--support-contact", + "support@example.com", + "--android-package-name", + "com.example.app", + "--accept-agreements", + ]) + .expect("native-extension flags should be accepted"); + } + + #[test] + fn native_extension_subcommand_name_is_optional() { + super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--support-contact", + "support@example.com", + "--android-package-name", + "com.example.app", + ]) + .expect("--name is optional"); + } + + #[test] + fn native_extension_subcommand_requires_support_contact() { + let err = super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--android-package-name", + "com.example.app", + ]) + .expect_err("--support-contact is required"); + let msg = err.to_string(); + assert!( + msg.contains("support-contact"), + "unexpected clap error: {msg}" + ); + } + + #[test] + fn native_extension_subcommand_requires_android_package_name() { + let err = super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--support-contact", + "support@example.com", + ]) + .expect_err("--android-package-name is required"); + let msg = err.to_string(); + assert!( + msg.contains("android-package-name"), + "unexpected clap error: {msg}" + ); + } + + #[test] + fn apply_native_extension_overwrites_and_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = test_config(); + crate::config::write_config(&path, &config).expect("write base"); + + super::native_extension::apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + crate::config::write_config(&path, &config).expect("write native_extension"); + + let read_back = crate::config::read_config(&path).expect("read back"); + let native = read_back + .native_extension + .expect("native_extension section written"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + + super::native_extension::apply_native_extension( + &mut config, + None, + "other@example.com".to_owned(), + "com.example.other".to_owned(), + ); + crate::config::write_config(&path, &config).expect("overwrite"); + let overwritten = crate::config::read_config(&path).expect("read overwrite"); + let native = overwritten.native_extension.expect("still present"); + assert_eq!(native.name, None); + assert_eq!(native.support_contact, "other@example.com"); + assert_eq!(native.android_package_name, "com.example.other"); + } + + #[test] + fn invalid_support_email_is_rejected_during_local_preparation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + + let error = + super::native_extension::prepare_native_extension(&path, &native_args("not-an-email")) + .expect_err("invalid support email must fail"); + + assert!(error.to_string().contains("valid email address"), "{error}"); + assert!( + crate::config::read_config(&path) + .expect("read unchanged config") + .native_extension + .is_none() + ); + } + + #[test] + fn application_name_lookup_uses_registry_id_not_oauth_client_id() { + let data = json!({ + "application": { + "id": "app-registry-id", + "clientId": "550e8400-e29b-41d4-a716-446655440000", + "name": "my-app" + } + }); + assert_eq!( + super::native_extension::application_id(&data, "my-app").expect("application id"), + "app-registry-id" + ); + } + + #[tokio::test] + async fn sync_resolves_application_and_organization_then_creates_draft() { + let app_registry = MockServer::start_async().await; + let app_lookup = app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|request| request.body_string().contains(r#""name":"my-app""#)); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-registry-id", + "name": "my-app" + } + } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + super::native_extension::apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = super::native_extension::sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect("sync native extension"); + + assert_eq!(registration.application_id, "app-registry-id"); + assert_eq!( + registration.operation, + crate::platform::app::native_app_client::UpsertOperation::Created + ); + app_lookup.assert_async().await; + onboarding.assert_async().await; + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn remote_failure_leaves_local_manifest_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + let prepared = super::native_extension::prepare_native_extension( + &path, + &native_args("support@example.com"), + ) + .expect("prepare native extension"); + + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = super::native_extension::sync_native_extension( + &prepared, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect_err("remote update must fail"); + + assert!( + error.to_string().contains("PACKAGE_NAME_IMMUTABLE"), + "{error}" + ); + assert!( + crate::config::read_config(&path) + .expect("read original manifest") + .native_extension + .is_none() + ); + } + #[test] fn settings_subcommand_accepts_presentation_file_flag() { super::group() diff --git a/rust/src/platform/app/commands/add/native_extension.rs b/rust/src/platform/app/commands/add/native_extension.rs new file mode 100644 index 00000000..5b22e910 --- /dev/null +++ b/rust/src/platform/app/commands/add/native_extension.rs @@ -0,0 +1,792 @@ +//! `gddy platform app add native-extension` — synchronize a native Android app record. + +use std::io::IsTerminal; + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Stage, Tier}; +use serde_json::json; + +use super::super::schemas::ConfigNativeExtension; +use crate::platform::app::native_app_client::{NativeAppClient, NativeAppInput, UpsertOperation}; +use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; + +#[derive(Debug, Clone, clap::Args)] +pub(super) struct NativeExtensionArgs { + /// Display name for the native extension. Falls back to the app `name` + /// in godaddy.toml when omitted. + #[arg(long)] + pub(super) name: Option, + + /// Support contact email written into godaddy.toml as support_contact. + #[arg(long = "support-contact", value_name = "EMAIL")] + pub(super) support_contact: String, + + /// Android package name written into godaddy.toml as android_package_name. + #[arg(long = "android-package-name", value_name = "PACKAGE")] + pub(super) android_package_name: String, + + /// Accept GoDaddy Developer agreements non-interactively when onboarding + /// is still pending (required for non-TTY). Used only when creating a + /// native-app record. + #[arg(long)] + pub(super) accept_agreements: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeExtensionRegistration { + pub(super) application_id: String, + pub(super) operation: UpsertOperation, +} + +/// Native-app display name: `[native_extension].name` when present and +/// non-empty, otherwise the application name. +fn native_app_name(config: &crate::config::Config) -> &str { + config + .native_extension + .as_ref() + .and_then(|native| native.name.as_deref()) + .filter(|name| !name.is_empty()) + .unwrap_or(&config.name) +} + +pub(super) fn apply_native_extension( + config: &mut crate::config::Config, + name: Option, + support_contact: String, + android_package_name: String, +) { + config.native_extension = Some(crate::config::NativeExtensionConfig { + name, + support_contact, + android_package_name, + }); +} + +pub(super) fn prepare_native_extension( + path: &std::path::Path, + args: &NativeExtensionArgs, +) -> cli_engine::Result { + let mut config = crate::config::read_config(path) + .map_err(|error| crate::error::GddyError::config(error.to_string()).into_cli_error())?; + apply_native_extension( + &mut config, + args.name.clone(), + args.support_contact.clone(), + args.android_package_name.clone(), + ); + config + .validate() + .map_err(|error| crate::error::GddyError::validation(error.to_string()).into_cli_error())?; + // Serialize before any remote work so an invalid TOML shape cannot leave + // DevX Core ahead of the local manifest. + toml::to_string_pretty(&config) + .map_err(|error| crate::error::GddyError::config(error.to_string()).into_cli_error())?; + Ok(config) +} + +pub(super) fn application_id(data: &serde_json::Value, name: &str) -> cli_engine::Result { + let application = &data["application"]; + if application.is_null() { + return Err(crate::error::GddyError::not_found(format!( + "application {name:?} was not found" + )) + .with_system("applications") + .into_cli_error()); + } + + application["id"] + .as_str() + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + crate::error::GddyError::unexpected(format!( + "App Registry returned application {name:?} without an id" + )) + .with_system("applications") + .into_cli_error() + }) +} + +pub(super) async fn sync_native_extension( + config: &crate::config::Config, + token: &str, + app_registry_url: &str, + devx_core_url: &str, + accept_agreements: bool, + is_tty: bool, +) -> cli_engine::Result { + let app_registry = + crate::platform::app::client::ApplicationClient::new(app_registry_url, token.to_owned()); + let application = app_registry + .get_application(&config.name) + .await + .map_err(super::super::client_err)?; + let application_id = application_id(&application, &config.name)?; + + let native = config + .native_extension + .as_ref() + .expect("native extension is installed during preparation"); + let input = NativeAppInput { + name: native_app_name(config).to_owned(), + description: config.description.clone().unwrap_or_default(), + support_email: native.support_contact.clone(), + app_category: String::new(), + merchant_category: String::new(), + android_package_name: native.android_package_name.clone(), + status: "draft".to_owned(), + }; + // Only a create needs the agreement gate. An update never calls onboarding, + // and the PATCH carries only the fields this command owns. + let operation = NativeAppClient::new(devx_core_url, token) + .upsert(&application_id, &input, || async { + crate::platform::app::onboarding::ensure_ready_for_app_init_at( + token, + devx_core_url, + accept_agreements, + is_tty, + ) + .await + .map(|outcome| outcome.org_id) + }) + .await?; + + Ok(NativeExtensionRegistration { + application_id, + operation, + }) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "native-extension", + "Register a native Android extension", + ) + .with_long( + "Write a [native_extension] section to the godaddy.toml manifest in \ + the current directory and immediately create or update its DevX Core \ + native-app record. The command authenticates, looks up the App Registry \ + application by the manifest's name, and uses that application's ID. \ + support_contact and android_package_name are required; name is optional \ + and falls back to the application name. The local file is written only \ + after the remote record succeeds, and rerunning safely reconciles either \ + an existing remote record or an existing local section. The record is \ + requested as a draft, but DevX Core may currently store it as active. \ + Creating a record requires accepted developer agreements; a non-interactive \ + session passes --accept-agreements when onboarding is still pending. \ + Updating an existing record leaves portal-owned categories and description \ + unchanged.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) + .with_output_schema::() + .with_feature_flag(super::super::NATIVE_APPS_FLAG_KEY, Stage::Experimental), + |ctx, args: NativeExtensionArgs| async move { + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let config = prepare_native_extension(&path, &args)?; + let app_registry_url = crate::http::api_url_for_env(&ctx.middleware.env)?; + let devx_core_url = crate::environments::devx_core_url(&ctx.middleware.env) + .ok_or_else(|| { + crate::error::GddyError::config(format!( + "DevX Core URL is not configured for environment {:?}", + ctx.middleware.env + )) + .into_cli_error() + })?; + // Resolve credentials only after all local validation has passed. + let token = ctx.credential().await?.token; + let registration = sync_native_extension( + &config, + &token, + &app_registry_url, + &devx_core_url, + args.accept_agreements, + std::io::stdin().is_terminal(), + ) + .await?; + crate::config::write_config(&path, &config).map_err(|error| { + crate::error::GddyError::config(format!( + "The DevX Core native-app record was {} for application {}, but {} could not be updated: {error}", + registration.operation.as_str(), + registration.application_id, + path.display(), + )) + .with_fix("Rerun this idempotent command to reconcile the local manifest with the existing remote record.") + .into_cli_error() + })?; + let native = config + .native_extension + .as_ref() + .expect("native extension is installed during preparation"); + Ok(CommandResult::new(json!({ + "applicationId": registration.application_id, + "operation": registration.operation.as_str(), + "name": native_app_name(&config), + "supportContact": native.support_contact, + "androidPackageName": native.android_package_name, + })) + .with_next_actions(super::super::add_config_next_actions(&config.name))) + }, + ) +} + +#[cfg(test)] +mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + use super::*; + + fn test_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + redirect_uris: None, + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + fn native_args(support_contact: &str) -> NativeExtensionArgs { + NativeExtensionArgs { + name: Some("My Display Name".to_owned()), + support_contact: support_contact.to_owned(), + android_package_name: "com.example.app".to_owned(), + accept_agreements: false, + } + } + + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-registry-id", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + + #[test] + fn native_app_name_falls_back_to_application_name_when_absent_or_empty() { + let mut config = test_config(); + for name in [None, Some(String::new())] { + apply_native_extension( + &mut config, + name, + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + assert_eq!(native_app_name(&config), "my-app"); + } + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + assert_eq!(native_app_name(&config), "My Display Name"); + } + + #[test] + fn apply_overwrites_and_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = test_config(); + crate::config::write_config(&path, &config).expect("write base"); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + crate::config::write_config(&path, &config).expect("write native extension"); + + let native = crate::config::read_config(&path) + .expect("read back") + .native_extension + .expect("native extension"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + } + + #[test] + fn invalid_support_email_is_rejected_during_local_preparation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + + let error = prepare_native_extension(&path, &native_args("not-an-email")) + .expect_err("invalid support email must fail"); + + assert!(error.to_string().contains("valid email address"), "{error}"); + assert!( + crate::config::read_config(&path) + .expect("read unchanged config") + .native_extension + .is_none() + ); + } + + #[test] + fn application_name_lookup_uses_registry_id_not_oauth_client_id() { + let data = json!({ + "application": { + "id": "app-registry-id", + "clientId": "550e8400-e29b-41d4-a716-446655440000", + "name": "my-app" + } + }); + assert_eq!( + application_id(&data, "my-app").expect("application id"), + "app-registry-id" + ); + } + + #[tokio::test] + async fn sync_resolves_application_and_organization_then_creates_draft() { + let app_registry = MockServer::start_async().await; + let app_lookup = app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|request| request.body_string().contains(r#""name":"my-app""#)); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect("sync native extension"); + + assert_eq!(registration.application_id, "app-registry-id"); + assert_eq!(registration.operation, UpsertOperation::Created); + app_lookup.assert_async().await; + onboarding.assert_async().await; + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn sync_updates_existing_record_without_calling_onboarding() { + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + // Onboarding is unavailable; an update must not depend on it. + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(503); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let update = devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "name": "My Display Name", + "supportEmail": "support@example.com", + "androidPackageName": "com.example.app" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect("update should not need onboarding"); + + assert_eq!(registration.operation, UpsertOperation::Updated); + get.assert_async().await; + update.assert_async().await; + assert_eq!(onboarding.calls_async().await, 0); + } + + #[tokio::test] + async fn sync_create_requires_accepted_agreements_when_onboarding_is_pending() { + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "PENDING" + } + })); + }) + .await; + let complete = devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let error = sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect_err("pending onboarding must block create"); + + let envelope = cli_engine::build_error_envelope(&error, "applications"); + assert_eq!( + envelope.error.as_ref().map(|item| item.code.as_str()), + Some("AGREEMENTS_REQUIRED") + ); + assert!( + error.to_string().contains("agreements must be accepted"), + "{error}" + ); + get.assert_async().await; + onboarding.assert_async().await; + assert_eq!(complete.calls_async().await, 0); + assert_eq!(create.calls_async().await, 0); + } + + #[tokio::test] + async fn sync_create_completes_pending_onboarding_when_agreements_are_accepted() { + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "PENDING" + } + })); + }) + .await; + let complete = devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + true, + false, + ) + .await + .expect("accepted agreements should allow create"); + + assert_eq!(registration.operation, UpsertOperation::Created); + complete.assert_async().await; + create.assert_async().await; + } + + #[tokio::test] + async fn remote_failure_leaves_local_manifest_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + let prepared = prepare_native_extension(&path, &native_args("support@example.com")) + .expect("prepare native extension"); + + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = sync_native_extension( + &prepared, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + false, + false, + ) + .await + .expect_err("remote update must fail"); + + assert!( + error.to_string().contains("PACKAGE_NAME_IMMUTABLE"), + "{error}" + ); + assert!( + crate::config::read_config(&path) + .expect("read original manifest") + .native_extension + .is_none() + ); + } +} diff --git a/rust/src/platform/app/commands/deploy/extensions.rs b/rust/src/platform/app/commands/deploy/extensions.rs index e5b5e2d5..1b6c1d01 100644 --- a/rust/src/platform/app/commands/deploy/extensions.rs +++ b/rust/src/platform/app/commands/deploy/extensions.rs @@ -345,6 +345,7 @@ mod tests { dependencies: vec![], extensions, settings: vec![], + native_extension: None, } } diff --git a/rust/src/platform/app/commands/deploy/mod.rs b/rust/src/platform/app/commands/deploy/mod.rs index 641d68c7..a4d9b844 100644 --- a/rust/src/platform/app/commands/deploy/mod.rs +++ b/rust/src/platform/app/commands/deploy/mod.rs @@ -444,6 +444,7 @@ mod tests { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, }; let input = super::manifest_metadata_input(&config); diff --git a/rust/src/platform/app/commands/import.rs b/rust/src/platform/app/commands/import.rs index 8f59a525..2288dc16 100644 --- a/rust/src/platform/app/commands/import.rs +++ b/rust/src/platform/app/commands/import.rs @@ -162,11 +162,11 @@ fn read_existing_config( } /// Guards against carrying a *different* application's locally-authored -/// sections (actions, dependencies, extensions, settings, version) into the -/// application being imported, e.g. running `import b` in a directory whose -/// `godaddy.toml` still describes application `a`. Returns `None` when there -/// is nothing to preserve (no existing manifest, or one that's confirmed to -/// belong to `name`). +/// sections (actions, dependencies, extensions, settings, native_extension, +/// version) into the application being imported, e.g. running `import b` in a +/// directory whose `godaddy.toml` still describes application `a`. Returns +/// `None` when there is nothing to preserve (no existing manifest, or one +/// that's confirmed to belong to `name`). fn existing_config_for( existing: Option, name: &str, @@ -183,7 +183,7 @@ fn existing_config_for( "godaddy.toml in this directory belongs to application '{}', not '{name}'. Re-run \ in a directory with '{name}''s manifest (or none), or pass --force to overwrite it \ and discard '{}'s locally-authored sections (actions, dependencies, extensions, \ - settings).", + settings, native_extension).", cfg.name, cfg.name ))); } @@ -280,9 +280,9 @@ pub(super) async fn run( let webhook_subscriptions = subscriptions_from_latest_release(app, &proxy_url); // Preserve locally-authored fields the API doesn't track (actions, - // dependencies, extensions, settings), if a godaddy.toml for *this* - // application already exists; this command only syncs identity, version, - // and webhook subscriptions, not the whole manifest. + // dependencies, extensions, settings, native_extension), if a godaddy.toml + // for *this* application already exists; this command only syncs identity, + // version, and webhook subscriptions, not the whole manifest. let existing = read_existing_config(&config_path)?; let existing = existing_config_for(existing, &name, force)?; @@ -317,7 +317,8 @@ pub(super) async fn run( .as_ref() .map(|c| c.settings.clone()) .unwrap_or_default(); - let extensions = existing.and_then(|c| c.extensions); + let extensions = existing.as_ref().and_then(|c| c.extensions.clone()); + let native_extension = existing.and_then(|c| c.native_extension); let config = crate::config::Config { name: name.clone(), @@ -335,6 +336,7 @@ pub(super) async fn run( dependencies, extensions, settings, + native_extension, }; crate::config::write_config(&config_path, &config).map_err(|e| { @@ -386,7 +388,8 @@ pub(super) fn command() -> RuntimeCommandSpec { release's webhook subscriptions, and write them to a godaddy.toml manifest \ in the current directory. Syncs identity, version, and webhook subscriptions \ from the remote application, while preserving locally-authored sections \ - (actions, dependencies, extensions, settings). Read-only against the API (no \ + (actions, dependencies, extensions, settings, native_extension). \ + Read-only against the API (no \ application is created, no .env is written), so it's safe to re-run to \ re-sync webhook subscriptions after a new release. Use `gddy platform app \ update` to change label/description; url, proxy-url, and scopes are not \ @@ -440,6 +443,7 @@ mod tests { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, } } diff --git a/rust/src/platform/app/commands/init.rs b/rust/src/platform/app/commands/init.rs index 724d939f..de81a09a 100644 --- a/rust/src/platform/app/commands/init.rs +++ b/rust/src/platform/app/commands/init.rs @@ -264,6 +264,7 @@ pub(super) fn command() -> RuntimeCommandSpec { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, }; let cwd = match std::env::current_dir() { Ok(dir) => dir, diff --git a/rust/src/platform/app/commands/mod.rs b/rust/src/platform/app/commands/mod.rs index 26cfdeff..52f5225e 100644 --- a/rust/src/platform/app/commands/mod.rs +++ b/rust/src/platform/app/commands/mod.rs @@ -6,6 +6,11 @@ use cli_engine::{GroupSpec, NextAction, NextActionParam, RuntimeGroupSpec}; use crate::http::api_url_for_env; use crate::next_action::{next_action, required_value}; +/// Feature-flag key for native-app CLI surfaces. `Stage::Experimental`. +/// `platform app add native-extension` declares it. `platform app release` +/// consults it before attaching `nativeExtensions`. +pub(crate) const NATIVE_APPS_FLAG_KEY: &str = "native-apps"; + mod add; mod add_extension; mod config; diff --git a/rust/src/platform/app/commands/release.rs b/rust/src/platform/app/commands/release.rs index 332ed573..8f3d1e31 100644 --- a/rust/src/platform/app/commands/release.rs +++ b/rust/src/platform/app/commands/release.rs @@ -12,6 +12,8 @@ use crate::config::settings_form::{ use crate::next_action::next_action; use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; +mod native_extension; + /// Build one `uiExtensions` release entry, enforcing the API's one-target-per- /// extension limit. `target` is omitted when the extension has no targets. fn ui_extension_entry( @@ -238,7 +240,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let config_path = crate::config::config_path(Some(&ctx.middleware.env)); let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new("")); // Pulls actions/subscriptions/uiExtensions/settings from godaddy.toml; see load_manifest. - let (actions, subscriptions, ui_extensions, settings) = + let (actions, subscriptions, ui_extensions, settings, native_extension) = match load_manifest(&config_path)? { Some(config) => { let actions: Vec = config @@ -260,14 +262,29 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or_default(); let ui_extensions = build_ui_extensions(&config)?; let settings = build_settings(&config, manifest_dir)?; - (actions, subscriptions, ui_extensions, settings) + let native_extension = native_extension::native_extension_draft_if_visible( + &config, + &ctx.middleware.flag_policy, + ); + ( + actions, + subscriptions, + ui_extensions, + settings, + native_extension, + ) } - None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + None => (Vec::new(), Vec::new(), Vec::new(), Vec::new(), None), }; input["actions"] = json!(actions); input["subscriptions"] = json!(subscriptions); input["uiExtensions"] = json!(ui_extensions); input["settings"] = json!(settings); + native_extension::apply_native_extensions( + &mut input, + &ui_extensions, + native_extension.as_ref(), + )?; let client = super::make_client(&ctx).await?; let data = client diff --git a/rust/src/platform/app/commands/release/native_extension.rs b/rust/src/platform/app/commands/release/native_extension.rs new file mode 100644 index 00000000..43b54aeb --- /dev/null +++ b/rust/src/platform/app/commands/release/native_extension.rs @@ -0,0 +1,318 @@ +//! Native-extension input for `gddy platform app release`, gated by the +//! `native-apps` feature flag. + +use serde_json::{Value, json}; + +use crate::platform::app::commands::NATIVE_APPS_FLAG_KEY; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeExtensionDraft { + name: String, + support_contact: String, + android_package_name: String, +} + +/// Resolve toml-owned native-extension fields for `ensureNativeAppDraft`. +/// +/// `name` uses `[native_extension].name` when it is present and non-empty; +/// otherwise it falls back to top-level `config.name`. +fn native_extension_draft(config: &crate::config::Config) -> Option { + let ext = config.native_extension.as_ref()?; + let name = ext + .name + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(config.name.as_str()) + .to_owned(); + Some(NativeExtensionDraft { + name, + support_contact: ext.support_contact.clone(), + android_package_name: ext.android_package_name.clone(), + }) +} + +/// Draft for `createRelease` when the `native-apps` flag is visible. +/// +/// A hidden flag yields `None` even if `[native_extension]` is present, so +/// `apply_native_extensions` omits `nativeExtensions` and skips the mix check. +pub(super) fn native_extension_draft_if_visible( + config: &crate::config::Config, + policy: &cli_engine::FlagPolicy, +) -> Option { + if !policy.visible(Some(NATIVE_APPS_FLAG_KEY), cli_engine::Stage::Experimental) { + return None; + } + native_extension_draft(config) +} + +/// Build the `createRelease` `nativeExtensions` entry from toml-owned fields. +/// +/// `platform` is required (`ANDROID` is the only `NativeExtensionPlatform` +/// variant). Categories are portal-owned and are not part of this input. +/// Unlike core `createGpaRelease`, this includes `packageName` from toml. +fn native_extensions_input(draft: &NativeExtensionDraft) -> Value { + json!([{ + "platform": "ANDROID", + "name": draft.name, + "contact": draft.support_contact, + "packageName": draft.android_package_name, + }]) +} + +/// Attach toml native-extension fields to the GraphQL `createRelease` input. +/// +/// Registry rejects mixing non-empty `uiExtensions` with `nativeExtensions` +/// (`HYBRID_EXTENSIONS_NOT_ALLOWED`). Empty `uiExtensions: []` is allowed. +pub(super) fn apply_native_extensions( + input: &mut Value, + ui_extensions: &[Value], + draft: Option<&NativeExtensionDraft>, +) -> cli_engine::Result<()> { + if draft.is_some() && !ui_extensions.is_empty() { + return Err(crate::error::GddyError::validation( + "a release cannot mix uiExtensions and a native extension", + ) + .into_cli_error()); + } + if let Some(draft) = draft { + input["nativeExtensions"] = native_extensions_input(draft); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use cli_engine::{FlagPolicy, Stage}; + + fn valid_release_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + redirect_uris: None, + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + fn config_with_native_extension() -> crate::config::Config { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: Some("My Display Name".to_owned()), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + config + } + + #[test] + fn native_apps_flag_key_is_native_apps() { + assert_eq!(super::NATIVE_APPS_FLAG_KEY, "native-apps"); + } + + #[test] + fn native_extension_draft_if_visible_is_none_at_ga_when_section_present() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new(); + let draft = super::native_extension_draft_if_visible(&config, &policy); + assert!(draft.is_none()); + + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], draft.as_ref()) + .expect("non-native release"); + assert!(input.get("nativeExtensions").is_none()); + } + + #[test] + fn native_extension_draft_if_visible_is_some_when_min_stage_is_experimental() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new().with_min_stage(Stage::Experimental); + let draft = super::native_extension_draft_if_visible(&config, &policy).expect("draft"); + assert_eq!(draft.name, "My Display Name"); + assert_eq!(draft.support_contact, "support@example.com"); + assert_eq!(draft.android_package_name, "com.example.app"); + + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], Some(&draft)).expect("native release"); + assert_eq!( + input["nativeExtensions"][0]["packageName"], + "com.example.app" + ); + } + + #[test] + fn native_extension_draft_if_visible_is_some_when_override_promotes_key_to_ga() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new() + .with_min_stage(Stage::Ga) + .with_override(super::NATIVE_APPS_FLAG_KEY, Stage::Ga); + let draft = super::native_extension_draft_if_visible(&config, &policy).expect("draft"); + assert_eq!(draft.android_package_name, "com.example.app"); + } + + #[test] + fn native_extension_draft_if_visible_stays_none_when_override_is_experimental_at_ga() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new() + .with_min_stage(Stage::Ga) + .with_override(super::NATIVE_APPS_FLAG_KEY, Stage::Experimental); + assert!(super::native_extension_draft_if_visible(&config, &policy).is_none()); + } + + #[test] + fn native_extension_draft_if_visible_ignores_native_section_beside_ui_extensions_at_ga() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new(); + let draft = super::native_extension_draft_if_visible(&config, &policy); + assert!(draft.is_none()); + + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + super::apply_native_extensions(&mut input, &ui, draft.as_ref()).expect("ui release"); + assert!(input.get("nativeExtensions").is_none()); + assert_eq!(ui.len(), 1); + } + + #[test] + fn native_extension_draft_if_visible_rejects_mix_when_flag_visible() { + let config = config_with_native_extension(); + let policy = FlagPolicy::new().with_min_stage(Stage::Experimental); + let draft = super::native_extension_draft_if_visible(&config, &policy).expect("draft"); + + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let err = super::apply_native_extensions(&mut input, &ui, Some(&draft)) + .expect_err("hybrid must fail locally"); + assert!( + err.to_string().contains("cannot mix uiExtensions"), + "got: {err}" + ); + assert!(input.get("nativeExtensions").is_none()); + } + + #[test] + fn native_extension_draft_if_visible_is_none_without_section_for_either_policy() { + let config = valid_release_config(); + assert!(super::native_extension_draft_if_visible(&config, &FlagPolicy::new()).is_none()); + assert!( + super::native_extension_draft_if_visible( + &config, + &FlagPolicy::new().with_min_stage(Stage::Experimental), + ) + .is_none() + ); + } + + #[test] + fn native_extension_draft_is_none_when_section_absent() { + let config = valid_release_config(); + assert!(super::native_extension_draft(&config).is_none()); + } + + #[test] + fn native_extension_draft_falls_back_to_config_name_when_name_absent() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: None, + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "my-app"); + assert_eq!(draft.support_contact, "support@example.com"); + assert_eq!(draft.android_package_name, "com.example.app"); + } + + #[test] + fn native_extension_draft_falls_back_to_config_name_when_name_empty() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: Some(String::new()), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "my-app"); + } + + #[test] + fn native_extension_draft_uses_explicit_name_when_present() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: Some("My Display Name".to_owned()), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "My Display Name"); + } + + #[test] + fn native_extensions_input_carries_every_toml_field_and_android_platform() { + let draft = super::NativeExtensionDraft { + name: "My Display Name".to_owned(), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }; + + let actual = super::native_extensions_input(&draft); + + let entries = actual.as_array().expect("one-element array"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["platform"], "ANDROID"); + assert_eq!(entries[0]["name"], "My Display Name"); + assert_eq!(entries[0]["contact"], "support@example.com"); + assert_eq!(entries[0]["packageName"], "com.example.app"); + // Categories are portal-owned and never travel on createRelease. + assert!(entries[0].get("appCategory").is_none()); + assert!(entries[0].get("merchantCategory").is_none()); + } + + fn sample_draft() -> super::NativeExtensionDraft { + super::NativeExtensionDraft { + name: "My Display Name".to_owned(), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + } + } + + #[test] + fn apply_native_extensions_sets_create_release_input_when_draft_present() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], Some(&sample_draft())) + .expect("native-only release"); + let row = &input["nativeExtensions"][0]; + assert_eq!(row["platform"], "ANDROID"); + assert_eq!(row["name"], "My Display Name"); + assert_eq!(row["contact"], "support@example.com"); + assert_eq!(row["packageName"], "com.example.app"); + } + + #[test] + fn apply_native_extensions_omits_key_when_draft_absent() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], None).expect("non-native release"); + assert!(input.get("nativeExtensions").is_none()); + } + + #[test] + fn apply_native_extensions_rejects_nonempty_ui_extensions() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let err = super::apply_native_extensions(&mut input, &ui, Some(&sample_draft())) + .expect_err("hybrid must fail locally"); + assert!( + err.to_string().contains("cannot mix uiExtensions"), + "got: {err}" + ); + assert!(input.get("nativeExtensions").is_none()); + } +} diff --git a/rust/src/platform/app/commands/schemas.rs b/rust/src/platform/app/commands/schemas.rs index ffbef8d7..724d036d 100644 --- a/rust/src/platform/app/commands/schemas.rs +++ b/rust/src/platform/app/commands/schemas.rs @@ -79,6 +79,7 @@ output_schema!(ApplicationRelease { "version": "string"; "description": "string", optional; "createdAt": "string"; + "nativeExtensions": "[]object", optional; }); output_schema!(ValidationResult { @@ -114,3 +115,11 @@ output_schema!(ConfigSetting { "slug": "string"; "entryPath": "string"; }); + +output_schema!(ConfigNativeExtension { + "applicationId": "string"; + "operation": "string"; + "name": "string", optional; + "supportContact": "string"; + "androidPackageName": "string"; +}); diff --git a/rust/src/platform/app/mod.rs b/rust/src/platform/app/mod.rs index 0e508df7..5085e97c 100644 --- a/rust/src/platform/app/mod.rs +++ b/rust/src/platform/app/mod.rs @@ -1,6 +1,7 @@ mod client; mod commands; mod extension; +pub(crate) mod native_app_client; mod onboarding; mod public_url; diff --git a/rust/src/platform/app/native_app_client.rs b/rust/src/platform/app/native_app_client.rs new file mode 100644 index 00000000..27aec0b6 --- /dev/null +++ b/rust/src/platform/app/native_app_client.rs @@ -0,0 +1,732 @@ +//! DevX Core client for native Android application drafts. + +use reqwest::RequestBuilder; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +const NATIVE_APPS_PATH: &str = "/api/v1/native-apps"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeAppInput { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) support_email: String, + pub(crate) app_category: String, + pub(crate) merchant_category: String, + pub(crate) android_package_name: String, + pub(crate) status: String, +} + +/// Fields this command owns on an existing record. +/// +/// Categories, description, and status stay off the PATCH. Core writes every +/// key that is present, so an empty string would clear a portal-owned value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeAppUpdate { + pub(crate) name: String, + pub(crate) support_email: String, + pub(crate) android_package_name: String, +} + +impl NativeAppUpdate { + fn from_input(input: &NativeAppInput) -> Self { + Self { + name: input.name.clone(), + support_email: input.support_email.clone(), + android_package_name: input.android_package_name.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeApp { + pub(crate) application_id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) support_email: String, + pub(crate) app_category: String, + pub(crate) merchant_category: String, + pub(crate) android_package_name: String, + pub(crate) status: String, + pub(crate) released: bool, + pub(crate) version_name: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpsertOperation { + Created, + Updated, +} + +impl UpsertOperation { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + } + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum NativeAppClientError { + #[error("DevX Core request failed: {0}")] + Network(#[from] reqwest::Error), + #[error("DevX Core returned HTTP {status}: {code}{message}")] + Api { + status: u16, + code: String, + message: DisplayMessage, + }, + #[error("DevX Core returned an invalid HTTP {status} response: {message}")] + InvalidResponse { status: u16, message: String }, +} + +#[derive(Debug)] +pub(crate) struct DisplayMessage(Option); + +impl std::fmt::Display for DisplayMessage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(message) = &self.0 { + write!(formatter, ": {message}") + } else { + Ok(()) + } + } +} + +impl From for cli_engine::CliCoreError { + fn from(error: NativeAppClientError) -> Self { + crate::error::GddyError::from(error).into_cli_error() + } +} + +impl From for crate::error::GddyError { + fn from(error: NativeAppClientError) -> Self { + match error { + NativeAppClientError::Network(error) => { + Self::network(format!("DevX Core request failed: {error}")) + .with_system("applications") + } + NativeAppClientError::Api { + status, + code, + message, + } if code == UPSTREAM_ERROR_CODE => Self::new( + UPSTREAM_ERROR_CODE, + format!("DevX Core returned HTTP {status}: {code}{message}"), + ) + .with_fix( + "The API is currently failing server-side. Retry, or check service health/incidents.", + ) + .with_system("applications"), + NativeAppClientError::Api { + status, + code, + message, + } => Self::from_http(status, format!("{code}{message}"), "applications"), + NativeAppClientError::InvalidResponse { status, message } => Self::from_http( + status, + format!("invalid DevX Core response: {message}"), + "applications", + ), + } + } +} + +#[derive(Debug, Deserialize)] +struct SuccessEnvelope { + success: bool, + data: T, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorPayload, +} + +#[derive(Debug, Deserialize)] +struct ErrorPayload { + code: String, + message: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateNativeAppInput<'a> { + organization_id: &'a str, + #[serde(flatten)] + native_app: &'a NativeAppInput, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SupportEmailInput<'a> { + support_email: &'a str, +} + +pub(crate) struct NativeAppClient { + base_url: String, + token: String, + http: reqwest::Client, +} + +impl NativeAppClient { + pub(crate) fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + token: token.into(), + http: crate::http::make_http_client(), + } + } + + pub(crate) async fn get( + &self, + application_id: &str, + ) -> Result, NativeAppClientError> { + let request = self + .http + .get(self.url(application_id)) + .bearer_auth(&self.token); + self.send(request).await + } + + pub(crate) async fn create( + &self, + application_id: &str, + organization_id: &str, + input: &NativeAppInput, + ) -> Result { + let request = self + .http + .post(self.url(application_id)) + .bearer_auth(&self.token) + .json(&CreateNativeAppInput { + organization_id, + native_app: input, + }); + self.send(request).await + } + + pub(crate) async fn update( + &self, + application_id: &str, + input: &NativeAppUpdate, + ) -> Result { + let request = self + .http + .patch(self.url(application_id)) + .bearer_auth(&self.token) + .json(input); + self.send(request).await + } + + async fn update_support_email( + &self, + application_id: &str, + support_email: &str, + ) -> Result { + let request = self + .http + .patch(self.url(application_id)) + .bearer_auth(&self.token) + .json(&SupportEmailInput { support_email }); + self.send(request).await + } + + /// Create or update the native app for `application_id`. + /// + /// `organization_id` is resolved only on the create path, so updating an + /// existing record never depends on it. + pub(crate) async fn upsert( + &self, + application_id: &str, + input: &NativeAppInput, + organization_id: F, + ) -> Result + where + E: From, + F: FnOnce() -> Fut, + Fut: Future>, + { + if self.get(application_id).await?.is_some() { + self.update(application_id, &NativeAppUpdate::from_input(input)) + .await?; + return Ok(UpsertOperation::Updated); + } + + let organization_id = organization_id().await?; + self.create(application_id, &organization_id, input).await?; + // DevX Portal currently follows create with this patch because + // application-service does not reliably persist supportEmail on create. + self.update_support_email(application_id, &input.support_email) + .await?; + Ok(UpsertOperation::Created) + } + + fn url(&self, application_id: &str) -> String { + let encoded_id: String = + url::form_urlencoded::byte_serialize(application_id.as_bytes()).collect(); + format!("{}{NATIVE_APPS_PATH}/{encoded_id}", self.base_url) + } + + async fn send( + &self, + request: RequestBuilder, + ) -> Result { + let request = request.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let response = self.http.execute(request).await?; + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + if !status.is_success() { + return Err(api_error(status.as_u16(), &bytes)); + } + + let envelope: SuccessEnvelope = serde_json::from_slice(&bytes).map_err(|error| { + NativeAppClientError::InvalidResponse { + status: status.as_u16(), + message: error.to_string(), + } + })?; + if !envelope.success { + return Err(NativeAppClientError::InvalidResponse { + status: status.as_u16(), + message: "success envelope contained success=false".to_owned(), + }); + } + Ok(envelope.data) + } +} + +/// Fallback error code for responses that are not a DevX Core error envelope — +/// typically a gateway or proxy error page rather than the service itself. +const UPSTREAM_ERROR_CODE: &str = "UPSTREAM_ERROR"; + +/// Upper bound on how much of a non-envelope body is echoed back to the user. +/// Proxies happily return whole HTML pages; those must not land in an error message. +const MAX_BODY_SNIPPET_BYTES: usize = 200; + +fn api_error(status: u16, body: &[u8]) -> NativeAppClientError { + if let Ok(envelope) = serde_json::from_slice::(body) { + return NativeAppClientError::Api { + status, + code: envelope.error.code, + message: DisplayMessage(envelope.error.message), + }; + } + + NativeAppClientError::Api { + status, + code: UPSTREAM_ERROR_CODE.to_owned(), + message: DisplayMessage(body_snippet(body)), + } +} + +/// Collapse a non-envelope response body into a short single-line snippet. +/// Returns `None` when the body carries no printable content. +fn body_snippet(body: &[u8]) -> Option { + let text = String::from_utf8_lossy(body); + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return None; + } + + if collapsed.len() <= MAX_BODY_SNIPPET_BYTES { + return Some(collapsed); + } + + // Truncate on a char boundary so multi-byte sequences are never split. + let mut end = MAX_BODY_SNIPPET_BYTES; + while end > 0 && !collapsed.is_char_boundary(end) { + end -= 1; + } + Some(format!("{}…", &collapsed[..end])) +} + +#[cfg(test)] +mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + use super::*; + + fn input() -> NativeAppInput { + NativeAppInput { + name: "Example Native App".to_owned(), + description: "Example description".to_owned(), + support_email: "support@example.com".to_owned(), + app_category: String::new(), + merchant_category: String::new(), + android_package_name: "com.example.app".to_owned(), + status: "draft".to_owned(), + } + } + + /// Note on `status`: these payload assertions pin what the CLI *sends*. + /// Upstream currently discards it — application-service omits `status` from + /// its PATCH DTO and hardcodes `ACTIVE` on create — so DevX Core's + /// `draft -> INACTIVE` mapping never takes effect and a read back reports + /// `active`. Do not read these assertions as proof that drafts work. + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-1", + "name": "Example Native App", + "description": "Example description", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + + #[tokio::test] + async fn get_sends_bearer_user_agent_and_decodes_success() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-1") + .header("authorization", "Bearer test-token") + .header( + "user-agent", + concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")), + ); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let app = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect("get native app") + .expect("native app exists"); + + mock.assert_async().await; + assert_eq!(app.application_id, "app-1"); + assert_eq!(app.android_package_name, "com.example.app"); + } + + #[tokio::test] + async fn upsert_creates_when_absent_and_patches_support_email() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = server + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-1") + .header("authorization", "Bearer test-token") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440000", + "name": "Example Native App", + "description": "Example description", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let operation = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", &input(), || async { + Ok::<_, NativeAppClientError>("550e8400-e29b-41d4-a716-446655440000".to_owned()) + }) + .await + .expect("create native app"); + + assert_eq!(operation, UpsertOperation::Created); + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn upsert_updates_when_present() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let update = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ + "name": "Example Native App", + "supportEmail": "support@example.com", + "androidPackageName": "com.example.app" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let operation = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", &input(), || async { + // Resolving the organization on the update path fails the test. + Err::(NativeAppClientError::InvalidResponse { + status: 0, + message: "organization must not be resolved on update".to_owned(), + }) + }) + .await + .expect("update native app"); + + assert_eq!(operation, UpsertOperation::Updated); + get.assert_async().await; + update.assert_async().await; + } + + #[tokio::test] + async fn error_envelope_preserves_service_code_and_message() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::PATCH).path("/api/v1/native-apps/app-1"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .update("app-1", &NativeAppUpdate::from_input(&input())) + .await + .expect_err("immutable package name must fail"); + let message = error.to_string(); + assert!(message.contains("PACKAGE_NAME_IMMUTABLE"), "{message}"); + assert!(message.contains("cannot change after release"), "{message}"); + } + + /// The follow-up `supportEmail` PATCH exists because application-service does + /// not reliably persist the field on create, so it is the likeliest step to + /// fail. When it does, `upsert` reports the failure even though the draft was + /// already created upstream — a later retry therefore reports `Updated`. + #[tokio::test] + async fn create_reports_failure_when_support_email_patch_fails() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = server + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/native-apps/app-1"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(500).json_body(json!({ + "success": false, + "error": { "code": "APPLICATION_SERVICE_ERROR", "message": "Update native app: HTTP 500" } + })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", &input(), || async { + Ok::<_, NativeAppClientError>("550e8400-e29b-41d4-a716-446655440000".to_owned()) + }) + .await + .expect_err("support email patch failure must surface"); + + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + assert!(matches!( + error, + NativeAppClientError::Api { status: 500, .. } + )); + let message = error.to_string(); + assert!(message.contains("APPLICATION_SERVICE_ERROR"), "{message}"); + } + + /// Gateways and proxies answer with HTML rather than a DevX Core envelope. + /// The page must not be echoed back wholesale as the error code. + #[tokio::test] + async fn non_envelope_error_body_is_reduced_to_a_bounded_snippet() { + let server = MockServer::start_async().await; + let filler = "gateway timed out ".repeat(40); + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(502) + .header("content-type", "text/html") + .body(format!("\n {filler}\n")); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("gateway error must fail"); + + let NativeAppClientError::Api { + status, + code, + message, + } = &error + else { + assert!( + matches!(error, NativeAppClientError::Api { .. }), + "expected an API error, got {error:?}" + ); + return; + }; + assert_eq!(*status, 502); + assert_eq!(code, UPSTREAM_ERROR_CODE); + let rendered = message.to_string(); + assert!(rendered.starts_with(": gateway"), "{rendered}"); + assert!(rendered.ends_with('…'), "{rendered}"); + let max_rendered = MAX_BODY_SNIPPET_BYTES + ": ".len() + '…'.len_utf8(); + assert!( + rendered.len() <= max_rendered, + "snippet not bounded: {} bytes exceeds {max_rendered}", + rendered.len() + ); + } + + #[tokio::test] + async fn error_envelope_without_message_renders_code_only() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(403) + .json_body(json!({ "success": false, "error": { "code": "FORBIDDEN" } })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("forbidden must fail"); + + assert_eq!( + error.to_string(), + "DevX Core returned HTTP 403: FORBIDDEN", + "a missing message must not leave a dangling separator" + ); + } + + #[test] + fn empty_error_body_carries_no_message() { + let error = api_error(504, b" \n "); + let NativeAppClientError::Api { code, message, .. } = &error else { + assert!( + matches!(error, NativeAppClientError::Api { .. }), + "expected an API error, got {error:?}" + ); + return; + }; + assert_eq!(code, UPSTREAM_ERROR_CODE); + assert_eq!(message.to_string(), ""); + } + + #[tokio::test] + async fn malformed_success_envelope_is_rejected() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": { "unexpected": true } })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("invalid native app response must fail"); + assert!(matches!( + error, + NativeAppClientError::InvalidResponse { status: 200, .. } + )); + } + + #[test] + fn non_envelope_error_keeps_upstream_error_code() { + use cli_engine::DetailedError; + + let error = crate::error::GddyError::from(api_error(502, b"gateway")); + assert_eq!(error.error_code(), UPSTREAM_ERROR_CODE); + let rendered = error.to_string(); + assert!(rendered.contains("HTTP 502"), "{rendered}"); + assert!(rendered.contains("UPSTREAM_ERROR"), "{rendered}"); + assert!(rendered.contains("gateway"), "{rendered}"); + } + + #[test] + fn envelope_error_still_maps_from_http_status() { + use cli_engine::DetailedError; + + let error = crate::error::GddyError::from(NativeAppClientError::Api { + status: 404, + code: "NATIVE_APP_NOT_FOUND".to_owned(), + message: DisplayMessage(Some("missing".to_owned())), + }); + assert_eq!(error.error_code(), "NOT_FOUND"); + } + + #[test] + fn url_encodes_application_id_path_segment() { + let client = NativeAppClient::new("https://example.test/", "token"); + assert_eq!( + client.url("app/with space"), + "https://example.test/api/v1/native-apps/app%2Fwith+space" + ); + } +} diff --git a/rust/src/platform/app/onboarding/ensure.rs b/rust/src/platform/app/onboarding/ensure.rs index 24346b8e..9092a501 100644 --- a/rust/src/platform/app/onboarding/ensure.rs +++ b/rust/src/platform/app/onboarding/ensure.rs @@ -46,7 +46,8 @@ impl DetailedError for AgreementsRequiredError { } } -/// Ensure the authenticated customer has an active org before `application init`. +/// Ensure the authenticated customer has an active org before creating an +/// application or a native-app record. /// /// Prompts only for `PENDING` users. Does not run during `auth login`. pub async fn ensure_ready_for_app_init( @@ -61,6 +62,23 @@ pub async fn ensure_ready_for_app_init( ))); }; + ensure_ready_for_app_init_at( + token, + &base_url, + accept_agreements, + io::stdin().is_terminal(), + ) + .await +} + +/// Same gate as [`ensure_ready_for_app_init`], against an already resolved DevX +/// Core URL and a caller-supplied terminal state. +pub(crate) async fn ensure_ready_for_app_init_at( + token: &str, + base_url: &str, + accept_agreements: bool, + is_tty: bool, +) -> Result { let client = OnboardingClient::new(base_url); let status = client.status(token).await.map_err(|error| { CliCoreError::message(format!( @@ -68,7 +86,6 @@ pub async fn ensure_ready_for_app_init( )) })?; - let is_tty = io::stdin().is_terminal(); let prompt_accepted = if status.status == "PENDING" && is_tty { // Keep the stdin lock off the async path so the command future stays `Send`. let prompt_result = { diff --git a/rust/src/platform/app/onboarding/mod.rs b/rust/src/platform/app/onboarding/mod.rs index 03be1ba1..002bafef 100644 --- a/rust/src/platform/app/onboarding/mod.rs +++ b/rust/src/platform/app/onboarding/mod.rs @@ -5,6 +5,7 @@ mod prompt; mod types; pub use ensure::ensure_ready_for_app_init; +pub(crate) use ensure::ensure_ready_for_app_init_at; // Only consumed by this module family's own `#[cfg(test)]` code // (`flow.rs`/`client.rs` tests reach it via this path, not `super::types` // directly) — gated the same way to avoid an unused-import warning on a diff --git a/rust/src/platform/guides/platform-overview.md b/rust/src/platform/guides/platform-overview.md index 0863b159..805886ad 100644 --- a/rust/src/platform/guides/platform-overview.md +++ b/rust/src/platform/guides/platform-overview.md @@ -28,9 +28,9 @@ redirect_uris = [ The list accepts up to five unique HTTPS URLs, each no longer than 2048 characters. Credentials and fragments are not allowed, and the list must not repeat `url` or the root-relative `/api/godaddy/callback` resolved against `url`. Omitting `redirect_uris` leaves the existing App Registry allowlist unchanged during an update; `redirect_uris = []` clears the additional callbacks. `init --config` sends the list when creating an application. Both `update` (including a label/description-only update) and `deploy` synchronize it when the key is present, so an explicit empty list clears remote extras in either flow. The list contains extras only and does not replace the callbacks registered automatically from `url`. -## 2. Configure it locally +## 2. Configure it -`gddy platform app add ` appends to `godaddy.toml` without any network call: +Most `gddy platform app add ` commands only append to `godaddy.toml`: - `add action --name --url ` — an HTTP endpoint the platform calls on the app's behalf. - `add subscription --name --url --events ` — a webhook route for platform events; run `gddy platform webhook event` to see valid event types. diff --git a/rust/src/platform/mod.rs b/rust/src/platform/mod.rs index cdb35a41..178a6611 100644 --- a/rust/src/platform/mod.rs +++ b/rust/src/platform/mod.rs @@ -34,3 +34,19 @@ pub fn module() -> Module { ), ]) } + +#[cfg(test)] +mod tests { + #[test] + fn platform_overview_guide_does_not_document_native_extension() { + let guide = include_str!("guides/platform-overview.md"); + assert!( + !guide.contains("native-extension"), + "overview guide still documents add native-extension" + ); + assert!( + !guide.contains("[native_extension]"), + "overview guide still documents the native_extension section" + ); + } +}