Skip to content
27 changes: 27 additions & 0 deletions rust/src/application/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,33 @@ mod tests {
);
}

/// Distinct from `activate_release_surfaces_graphql_errors`: GraphQL
/// reports failures as HTTP 200 with an `errors` array, but the
/// transport itself (auth rejected, gateway down, etc.) can also fail
/// at the HTTP layer with a non-2xx status. That path maps to
/// `ClientError::Http`, not `ClientError::GraphQL`.
#[tokio::test]
async fn query_maps_a_non_2xx_status_to_a_http_client_error() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(POST).path("/v1/apps/app-registry-subgraph");
then.status(401).body("unauthorized");
})
.await;

let err = ApplicationClient::new(server.base_url(), "test-token")
.get_application("test-app")
.await
.expect_err("non-2xx status should surface as an HTTP error");

mock.assert_async().await;
assert!(
matches!(err, ClientError::Http { status: 401, ref body } if body.contains("unauthorized")),
"expected Http error variant, got: {err:?}"
);
}

#[tokio::test]
async fn update_application_promotes_to_active() {
let server = MockServer::start_async().await;
Expand Down
139 changes: 139 additions & 0 deletions rust/src/application/commands/deploy/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,145 @@ pub(super) async fn deploy_extension(

#[cfg(test)]
mod tests {
use crate::config::{
BlocksExtensionConfig, CheckoutExtensionConfig, Config, EmbedExtensionConfig,
ExtensionTarget, ExtensionsConfig,
};

/// Minimal `Config` with placeholder values for every field
/// `collect_extensions` doesn't read — only `extensions` varies per test.
fn base_config(extensions: Option<ExtensionsConfig>) -> Config {
Config {
name: "test-app".to_owned(),
client_id: "00000000-0000-4000-8000-000000000000".to_owned(),
description: None,
version: "1.0.0".to_owned(),
url: "https://example.com".to_owned(),
proxy_url: "https://proxy.example.com".to_owned(),
authorization_scopes: vec![],
actions: vec![],
subscriptions: None,
dependencies: vec![],
extensions,
}
}

#[test]
fn collect_extensions_returns_empty_when_none_configured() {
assert!(super::collect_extensions(&base_config(None)).is_empty());
}

#[test]
fn collect_extensions_maps_embed_extensions() {
let config = base_config(Some(ExtensionsConfig {
embed: vec![EmbedExtensionConfig {
name: "@test/embed-one".to_owned(),
handle: "embed-one".to_owned(),
source: "src/index.ts".to_owned(),
targets: vec![ExtensionTarget {
target: "admin.product.detail".to_owned(),
}],
}],
checkout: vec![],
blocks: None,
}));

let deploys = super::collect_extensions(&config);
assert_eq!(deploys.len(), 1);
assert_eq!(deploys[0].name, "@test/embed-one");
assert_eq!(deploys[0].handle, "embed-one");
assert_eq!(deploys[0].source, "src/index.ts");
assert_eq!(deploys[0].ext_type, crate::extension::ExtensionType::Embed);
assert_eq!(deploys[0].targets, vec!["admin.product.detail".to_owned()]);
}

#[test]
fn collect_extensions_maps_checkout_extensions() {
let config = base_config(Some(ExtensionsConfig {
embed: vec![],
checkout: vec![CheckoutExtensionConfig {
name: "@test/checkout-one".to_owned(),
handle: "checkout-one".to_owned(),
source: "src/checkout.ts".to_owned(),
targets: vec![],
}],
blocks: None,
}));

let deploys = super::collect_extensions(&config);
assert_eq!(deploys.len(), 1);
assert_eq!(deploys[0].name, "@test/checkout-one");
assert_eq!(
deploys[0].ext_type,
crate::extension::ExtensionType::Checkout
);
assert!(deploys[0].targets.is_empty());
}

#[test]
fn collect_extensions_maps_blocks_extension_with_fixed_name_and_handle() {
let config = base_config(Some(ExtensionsConfig {
embed: vec![],
checkout: vec![],
blocks: Some(BlocksExtensionConfig {
source: "src/blocks.ts".to_owned(),
}),
}));

let deploys = super::collect_extensions(&config);
assert_eq!(deploys.len(), 1);
// Blocks has no per-extension name/handle in godaddy.toml — these are
// fixed, matching the single implicit "blocks" upload target.
assert_eq!(deploys[0].name, "Blocks");
assert_eq!(deploys[0].handle, "blocks");
assert_eq!(deploys[0].source, "src/blocks.ts");
assert_eq!(deploys[0].ext_type, crate::extension::ExtensionType::Blocks);
assert!(deploys[0].targets.is_empty());
}

#[test]
fn collect_extensions_preserves_embed_then_checkout_then_blocks_order() {
let config = base_config(Some(ExtensionsConfig {
embed: vec![EmbedExtensionConfig {
name: "@test/embed".to_owned(),
handle: "embed".to_owned(),
source: "src/embed.ts".to_owned(),
targets: vec![],
}],
checkout: vec![CheckoutExtensionConfig {
name: "@test/checkout".to_owned(),
handle: "checkout".to_owned(),
source: "src/checkout.ts".to_owned(),
targets: vec![],
}],
blocks: Some(BlocksExtensionConfig {
source: "src/blocks.ts".to_owned(),
}),
}));

let deploys = super::collect_extensions(&config);
let names: Vec<&str> = deploys.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["@test/embed", "@test/checkout", "Blocks"]);
}

#[test]
fn upload_completed_event_omits_percent_when_not_final_target() {
let target = Some("admin.product.detail".to_owned());

let event = super::upload_completed_event("widget", &target, false, 1, 2);

assert_eq!(
event,
serde_json::json!({
"type": "progress",
"name": "extension.upload",
"status": "completed",
"extensionName": "widget",
"target": "admin.product.detail",
})
);
}

#[test]
fn resolve_upload_targets_by_type() {
use crate::extension::ExtensionType;
Expand Down
85 changes: 85 additions & 0 deletions rust/src/extension/security/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,89 @@ mod tests {
sorted.sort_unstable();
assert_eq!(lines, sorted, "findings not sorted by line");
}

// -----------------------------------------------------------------------
// Performance regression guard (DEVEX-721 parity with the deleted TS
// security-scan.perf.test.ts). Rust's scanner takes a pre-bundled string
// rather than a directory of source files, so this targets scan_bundle
// directly, with generous, CI-safe bounds — a canary for algorithmic
// regressions (e.g. a new rule pattern with catastrophic backtracking
// under fancy_regex's lookaround engine), not a speed benchmark.
// -----------------------------------------------------------------------

/// One safe, moderately complex "module" repeated to build synthetic
/// bundles of a chosen size — mirrors the shape of the TS fixture's
/// generated modules (a class with a constructor, a `fetch` call, and a
/// `Buffer` call) so the benign-but-pattern-adjacent code the scanner
/// has to walk past is representative, not just blank lines.
fn synthetic_module(i: usize) -> String {
format!(
r#"
class Module{i} {{
constructor() {{
this.data = new Map();
}}
async fetchData(url) {{
return fetch(url);
}}
processBuffer(input) {{
return Buffer.from(input, "utf-8");
}}
}}
"#
)
}

fn synthetic_bundle(modules: usize) -> String {
(0..modules).map(synthetic_module).collect::<String>()
}

#[test]
fn scan_bundle_completes_within_a_generous_bound_on_a_realistic_bundle() {
let bundle = synthetic_bundle(200);
let start = std::time::Instant::now();
let findings = scan_bundle(&bundle, "bundle.mjs");
let elapsed = start.elapsed();

assert!(
elapsed.as_secs() < 5,
"scanning a 200-module bundle took {elapsed:?} — investigate for a \
catastrophic-backtracking regex regression"
);
// The synthetic bundle uses fetch()/Buffer.from() only, which trip
// warn-level rules, not any of the SEC101-110 block rules.
assert!(!is_blocked(&findings), "findings: {findings:?}");
}

/// Scanning time should grow roughly linearly with input size, not
/// quadratically or worse — a much looser bound than the TS test's <2x
/// (which measured dev-laptop wall-clock) to stay stable on noisy CI
/// runners, while still catching a real algorithmic regression.
#[test]
fn scan_bundle_time_does_not_blow_up_superlinearly_with_input_size() {
let small = synthetic_bundle(50);
let large = synthetic_bundle(400); // 8x the module count

let start = std::time::Instant::now();
scan_bundle(&small, "bundle.mjs");
let small_elapsed = start.elapsed();

let start = std::time::Instant::now();
scan_bundle(&large, "bundle.mjs");
let large_elapsed = start.elapsed();

// If the small run was too fast to measure meaningfully, there's
// nothing informative to compare — skip rather than divide by
// near-zero and produce a flaky ratio.
if small_elapsed.as_micros() < 100 {
return;
}

let ratio = large_elapsed.as_secs_f64() / small_elapsed.as_secs_f64();
assert!(
ratio < 40.0,
"8x input size took {ratio:.1}x longer to scan ({small_elapsed:?} -> \
{large_elapsed:?}) — looks superlinear"
);
}
}
91 changes: 70 additions & 21 deletions rust/src/hosting/nodejs/source/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ struct SourceUploadArgs {
file: String,
}

/// Ensure the given zip path exists as a file before attempting upload.
fn require_zip_file(file: &str) -> Result<(), crate::error::GddyError> {
if std::path::Path::new(file).is_file() {
return Ok(());
}
Err(crate::error::GddyError::validation(format!(
"zip file not found: {file}"
)))
}

/// Build the "poll status" next action, prefilling `--job-id` when the upload
/// response returned one, otherwise leaving it required for the user to fill in.
fn upload_status_next_action(app_id: String, upload_job_id: &str) -> cli_engine::NextAction {
let job_id_param = if upload_job_id.is_empty() {
NextActionParam::required()
} else {
NextActionParam::value(upload_job_id)
};
next_action(
"hosting nodejs source status --app-id <app-id> --job-id <job-id>",
"Poll zip upload status",
)
.with_param("app-id", NextActionParam::value(app_id))
.with_param("job-id", job_id_param)
}

pub(super) fn command() -> RuntimeCommandSpec {
RuntimeCommandSpec::new_typed_with_context::<SourceUploadArgs, _, _, _>(
CommandSpec::from_args::<SourceUploadArgs>(
Expand All @@ -35,35 +61,58 @@ pub(super) fn command() -> RuntimeCommandSpec {
|ctx, args: SourceUploadArgs| async move {
let app_id = args.app_id;
let file = args.file;
require_zip_file(&file).map_err(crate::error::GddyError::into_cli_error)?;
let path = std::path::Path::new(&file);
if !path.is_file() {
return Err(crate::error::GddyError::validation(format!(
"zip file not found: {file}"
))
.into_cli_error());
}
let client = make_client(&ctx, &[CODE_WRITE]).await?;
let data = client
.upload_source(&app_id, path)
.await
.map_err(client_err)?;
let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or("");
let action = if upload_job_id.is_empty() {
next_action(
"hosting nodejs source status --app-id <app-id> --job-id <job-id>",
"Poll zip upload status",
)
.with_param("app-id", NextActionParam::value(app_id))
.with_param("job-id", NextActionParam::required())
} else {
next_action(
"hosting nodejs source status --app-id <app-id> --job-id <job-id>",
"Poll zip upload status",
)
.with_param("app-id", NextActionParam::value(app_id))
.with_param("job-id", NextActionParam::value(upload_job_id))
};
let action = upload_status_next_action(app_id, upload_job_id);
Ok(CommandResult::new(data).with_next_actions(vec![action]))
},
)
}

#[cfg(test)]
mod tests {
use cli_engine::NextActionParam;

use super::{require_zip_file, upload_status_next_action};

#[test]
fn require_zip_file_rejects_missing_path() {
let err = require_zip_file("/no/such/file.zip").expect_err("missing file should error");
assert!(err.to_string().contains("zip file not found"));
assert!(err.to_string().contains("/no/such/file.zip"));
}

#[test]
fn require_zip_file_accepts_an_existing_file() {
let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file");
let path = tmp.path().to_str().expect("temp path should be utf8");
assert!(require_zip_file(path).is_ok());
}

#[test]
fn require_zip_file_rejects_a_directory() {
let tmp = tempfile::tempdir().expect("failed to create temp dir");
let path = tmp.path().to_str().expect("temp path should be utf8");
let err = require_zip_file(path).expect_err("directory should error");
assert!(err.to_string().contains("zip file not found"));
}

#[test]
fn upload_status_next_action_requires_job_id_when_absent() {
let action = upload_status_next_action("app-123".to_owned(), "");
assert_eq!(action.params["app-id"], NextActionParam::value("app-123"));
assert_eq!(action.params["job-id"], NextActionParam::required());
}

#[test]
fn upload_status_next_action_prefills_job_id_when_present() {
let action = upload_status_next_action("app-123".to_owned(), "job-456");
assert_eq!(action.params["job-id"], NextActionParam::value("job-456"));
}
}
Loading
Loading