Files
wfe/wfe-yaml/tests/deno.rs
Sienna Meridian Satterwhite 20f32531b7 chore: add nextest cover profile, update backward-compat imports
Nextest cover profile for cargo llvm-cov integration.
Update existing test imports from load_workflow_from_str to
load_single_workflow_from_str for backward compatibility.
2026-03-26 14:15:50 +00:00

598 lines
19 KiB
Rust

#![cfg(feature = "deno")]
use std::collections::HashMap;
use std::io::Write;
use wfe_yaml::executors::deno::config::{DenoConfig, DenoPermissions};
use wfe_yaml::executors::deno::module_loader::WfeModuleLoader;
use wfe_yaml::executors::deno::permissions::PermissionChecker;
use wfe_yaml::executors::deno::runtime::{create_runtime, would_auto_add_esm_sh};
use wfe_yaml::executors::deno::step::DenoStep;
use wfe_core::models::execution_pointer::ExecutionPointer;
use wfe_core::models::workflow_definition::WorkflowStep;
use wfe_core::models::workflow_instance::WorkflowInstance;
use wfe_core::traits::step::{StepBody, StepExecutionContext};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn default_config(script: &str) -> DenoConfig {
DenoConfig {
script: Some(script.to_string()),
file: None,
permissions: DenoPermissions::default(),
modules: vec![],
env: HashMap::new(),
timeout_ms: None,
}
}
fn make_context<'a>(
step: &'a WorkflowStep,
workflow: &'a WorkflowInstance,
pointer: &'a ExecutionPointer,
) -> StepExecutionContext<'a> {
StepExecutionContext {
item: None,
execution_pointer: pointer,
persistence_data: None,
step,
workflow,
cancellation_token: tokio_util::sync::CancellationToken::new(),
}
}
fn make_test_fixtures(
data: serde_json::Value,
) -> (WorkflowStep, WorkflowInstance, ExecutionPointer) {
let mut step = WorkflowStep::new(0, "deno");
step.name = Some("test-deno-step".to_string());
let workflow = WorkflowInstance::new("test-workflow", 1, data);
let pointer = ExecutionPointer::new(0);
(step, workflow, pointer)
}
// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn deno_inline_script_completes() {
let mut step = DenoStep::new(default_config("1 + 1;"));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
assert!(result.proceed);
}
#[tokio::test]
async fn deno_output_captured() {
let mut step = DenoStep::new(default_config(r#"output("greeting", "hello");"#));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
assert!(result.proceed);
let data = result.output_data.unwrap();
assert_eq!(data["greeting"], serde_json::json!("hello"));
}
#[tokio::test]
async fn deno_inputs_available() {
let script = r#"
const data = inputs();
output("got_name", data.name);
"#;
let mut step = DenoStep::new(default_config(script));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({"name": "alice"}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["got_name"], serde_json::json!("alice"));
}
#[tokio::test]
async fn deno_script_error_fails_step() {
let mut step = DenoStep::new(default_config("throw new Error('boom');"));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let err = step.run(&ctx).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("boom") || msg.contains("Error"), "got: {msg}");
}
#[tokio::test]
async fn deno_timeout_kills_execution() {
let mut config = default_config("while (true) {}");
config.timeout_ms = Some(100);
let mut step = DenoStep::new(config);
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let err = step.run(&ctx).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("timed out"), "got: {msg}");
}
#[tokio::test]
async fn deno_multiple_outputs() {
let script = r#"
output("a", 1);
output("b", "two");
output("c", true);
"#;
let mut step = DenoStep::new(default_config(script));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["a"], serde_json::json!(1));
assert_eq!(data["b"], serde_json::json!("two"));
assert_eq!(data["c"], serde_json::json!(true));
}
#[tokio::test]
async fn deno_log_works() {
let mut step = DenoStep::new(default_config(r#"log("hello from deno");"#));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
// Should not crash.
let result = step.run(&ctx).await.unwrap();
assert!(result.proceed);
}
#[tokio::test]
async fn deno_empty_script_completes() {
let mut step = DenoStep::new(default_config(""));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
assert!(result.proceed);
assert!(result.output_data.is_none());
}
// ---------------------------------------------------------------------------
// Permission integration tests
// ---------------------------------------------------------------------------
#[test]
fn permission_net_allowed() {
let perms = DenoPermissions {
net: vec!["api.example.com".to_string()],
..Default::default()
};
let checker = PermissionChecker::from_config(&perms);
assert!(checker.check_net("api.example.com").is_ok());
}
#[test]
fn permission_net_denied() {
let perms = DenoPermissions::default();
let checker = PermissionChecker::from_config(&perms);
assert!(checker.check_net("evil.com").is_err());
}
#[test]
fn permission_env_denied() {
let perms = DenoPermissions {
env: vec!["SAFE_VAR".to_string()],
..Default::default()
};
let checker = PermissionChecker::from_config(&perms);
assert!(checker.check_env("SECRET_KEY").is_err());
assert!(checker.check_env("SAFE_VAR").is_ok());
}
// ---------------------------------------------------------------------------
// Runtime creation tests
// ---------------------------------------------------------------------------
#[test]
fn runtime_creation_succeeds() {
let config = default_config("1");
let rt = create_runtime(&config, serde_json::json!({"x": 1}), "test");
assert!(rt.is_ok());
}
#[test]
fn runtime_op_state_populated() {
let config = default_config("1");
let rt = create_runtime(&config, serde_json::json!({"foo": "bar"}), "my-step").unwrap();
let state = rt.op_state();
let state = state.borrow();
let inputs = state.borrow::<wfe_yaml::executors::deno::ops::WorkflowInputs>();
assert_eq!(inputs.data, serde_json::json!({"foo": "bar"}));
}
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
#[test]
fn validation_deno_step_missing_config() {
let yaml = r#"
workflow:
id: test
version: 1
steps:
- name: run_js
type: deno
"#;
let config = HashMap::new();
let result = wfe_yaml::load_single_workflow_from_str(yaml, &config);
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("config") || msg.contains("Deno"),
"got: {msg}"
);
}
#[test]
fn validation_deno_step_missing_script_and_file() {
let yaml = r#"
workflow:
id: test
version: 1
steps:
- name: run_js
type: deno
config:
env:
FOO: bar
"#;
let config = HashMap::new();
let result = wfe_yaml::load_single_workflow_from_str(yaml, &config);
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("script") || msg.contains("file"),
"got: {msg}"
);
}
#[test]
fn compilation_deno_step_produces_factory() {
let yaml = r#"
workflow:
id: test-deno
version: 1
steps:
- name: js_step
type: deno
config:
script: "output('key', 'val');"
"#;
let config = HashMap::new();
let compiled = wfe_yaml::load_single_workflow_from_str(yaml, &config).unwrap();
assert!(!compiled.step_factories.is_empty());
let (key, _factory) = &compiled.step_factories[0];
assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}");
}
// ---------------------------------------------------------------------------
// Phase 4: HTTP op integration tests
// ---------------------------------------------------------------------------
fn config_with_net(script: &str, net_hosts: &[&str]) -> DenoConfig {
DenoConfig {
script: Some(script.to_string()),
file: None,
permissions: DenoPermissions {
net: net_hosts.iter().map(|s| s.to_string()).collect(),
..Default::default()
},
modules: vec![],
env: HashMap::new(),
timeout_ms: Some(10000),
}
}
#[tokio::test]
async fn deno_fetch_allowed_host() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let script = format!(
r#"
const resp = await fetch("{}");
output("status", resp.status);
output("body", await resp.text());
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["status"], serde_json::json!(200));
assert_eq!(data["body"], serde_json::json!("ok"));
}
#[tokio::test]
async fn deno_fetch_denied_host() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
// Do NOT add 127.0.0.1 to net permissions.
let script = format!(
r#"
try {{
await fetch("{}");
output("result", "should_not_reach");
}} catch (e) {{
output("error", e.message || String(e));
}}
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &[]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert!(
data.get("error").is_some(),
"expected permission error, got: {data:?}"
);
let err_msg = data["error"].as_str().unwrap();
assert!(
err_msg.contains("Permission denied"),
"got: {err_msg}"
);
}
#[tokio::test]
async fn deno_fetch_returns_json() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "alice", "age": 30})),
)
.mount(&server)
.await;
let script = format!(
r#"
const resp = await fetch("{}");
const data = await resp.json();
output("name", data.name);
output("age", data.age);
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["name"], serde_json::json!("alice"));
assert_eq!(data["age"], serde_json::json!(30));
}
#[tokio::test]
async fn deno_fetch_post_with_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::header("content-type", "application/json"))
.and(wiremock::matchers::body_json(serde_json::json!({"key": "val"})))
.respond_with(wiremock::ResponseTemplate::new(201).set_body_string("created"))
.mount(&server)
.await;
let script = format!(
r#"
const resp = await fetch("{}", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{ key: "val" }})
}});
output("status", resp.status);
output("body", await resp.text());
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["status"], serde_json::json!(201));
assert_eq!(data["body"], serde_json::json!("created"));
}
#[tokio::test]
async fn deno_fetch_no_permissions_denies_all() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::any())
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
// Empty net allowlist should deny everything.
let script = format!(
r#"
try {{
await fetch("{}");
output("result", "should_not_reach");
}} catch (e) {{
output("denied", true);
}}
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &[]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["denied"], serde_json::json!(true));
}
// ---------------------------------------------------------------------------
// Phase 5: Module loader integration tests
// ---------------------------------------------------------------------------
#[test]
fn deno_module_loader_resolves_npm_to_esm_sh() {
let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21");
assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string()));
let scoped = WfeModuleLoader::resolve_npm_specifier("npm:@scope/pkg@1.0.0");
assert_eq!(scoped, Some("https://esm.sh/@scope/pkg@1.0.0".to_string()));
// Non-npm specifiers return None.
assert!(WfeModuleLoader::resolve_npm_specifier("https://cdn.com/mod.js").is_none());
assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none());
}
#[test]
fn deno_esm_sh_auto_added_to_net_allowlist() {
// When modules are declared, esm.sh should be auto-added.
let config = DenoConfig {
script: Some("1".to_string()),
file: None,
permissions: DenoPermissions::default(),
modules: vec!["npm:lodash@4".to_string()],
env: HashMap::new(),
timeout_ms: None,
};
assert!(would_auto_add_esm_sh(&config));
// When no modules, esm.sh is not added.
let config_no_modules = DenoConfig {
script: Some("1".to_string()),
file: None,
permissions: DenoPermissions::default(),
modules: vec![],
env: HashMap::new(),
timeout_ms: None,
};
assert!(!would_auto_add_esm_sh(&config_no_modules));
// When esm.sh already present, not duplicated.
let config_already = DenoConfig {
script: Some("1".to_string()),
file: None,
permissions: DenoPermissions {
net: vec!["esm.sh".to_string()],
..Default::default()
},
modules: vec!["npm:lodash@4".to_string()],
env: HashMap::new(),
timeout_ms: None,
};
assert!(!would_auto_add_esm_sh(&config_already));
}
#[tokio::test]
async fn deno_import_local_file() {
// Create a temp JS file to import.
let dir = tempfile::tempdir().unwrap();
let helper_path = dir.path().join("helper.js");
let mut f = std::fs::File::create(&helper_path).unwrap();
writeln!(f, "export function greet(name) {{ return `hello ${{name}}`; }}").unwrap();
drop(f);
let main_path = dir.path().join("main.js");
let main_code = format!(
r#"import {{ greet }} from "file://{}";
output("greeting", greet("world"));"#,
helper_path.to_str().unwrap()
);
std::fs::write(&main_path, &main_code).unwrap();
let config = DenoConfig {
script: None,
file: Some(main_path.to_str().unwrap().to_string()),
permissions: DenoPermissions {
read: vec![dir.path().to_str().unwrap().to_string()],
..Default::default()
},
modules: vec![],
env: HashMap::new(),
timeout_ms: Some(5000),
};
let mut step = DenoStep::new(config);
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["greeting"], serde_json::json!("hello world"));
}
#[tokio::test]
async fn deno_dynamic_import_denied() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::any())
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_string("export const x = 1;"),
)
.mount(&server)
.await;
// dynamic_import defaults to false.
let script = format!(
r#"
try {{
await import("{}/mod.js");
output("result", "should_not_reach");
}} catch (e) {{
output("error", e.message || String(e));
}}
"#,
server.uri()
);
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert!(
data.get("error").is_some(),
"expected dynamic import to be denied, got: {data:?}"
);
let err_msg = data["error"].as_str().unwrap();
assert!(
err_msg.contains("Dynamic import") || err_msg.contains("not allowed"),
"expected dynamic import denied error, got: {err_msg}"
);
}
#[tokio::test]
async fn deno_import_npm_module() {
// Use a tiny npm package via esm.sh.
let script = r#"
import isNumber from "npm:is-number@7.0.0";
output("result", isNumber(42));
"#;
let config = DenoConfig {
script: Some(script.to_string()),
file: None,
permissions: DenoPermissions {
net: vec!["esm.sh".to_string()],
..Default::default()
},
modules: vec!["npm:is-number@7.0.0".to_string()],
env: HashMap::new(),
timeout_ms: Some(30000),
};
let mut step = DenoStep::new(config);
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
let ctx = make_context(&ws, &wf, &ptr);
let result = step.run(&ctx).await.unwrap();
let data = result.output_data.unwrap();
assert_eq!(data["result"], serde_json::json!(true));
}