feat(wfe-yaml): add HTTP ops, module loader, and npm support via esm.sh
Phase 4 — Permission-gated HTTP fetch op: - op_fetch with net permission check on every request - globalThis.fetch() wrapper with .json()/.text() methods - Supports GET/POST/PUT/DELETE with headers and body Phase 5 — Module loader: - WfeModuleLoader resolving npm: → esm.sh, https://, file://, relative paths - All resolution paths permission-checked - Bare path resolution (/) for esm.sh sub-module redirects - Dynamic import rejection unless permissions.dynamic_import: true - esm.sh auto-added to net allowlist when modules declared Mandatory npm integration test (is-number via esm.sh). 25 new tests. 133 total deno tests, 326 total workspace tests.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
#![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;
|
||||
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;
|
||||
@@ -267,3 +269,329 @@ workflow:
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user