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:
2026-03-25 23:02:51 +00:00
parent 6fec7dbab5
commit 1a84da40bf
11 changed files with 1026 additions and 6 deletions

View File

@@ -6,7 +6,7 @@ description = "YAML workflow definitions for WFE"
[features]
default = []
deno = ["deno_core", "url"]
deno = ["deno_core", "deno_error", "url", "reqwest"]
[dependencies]
wfe-core = { workspace = true }
@@ -19,7 +19,9 @@ thiserror = { workspace = true }
tracing = { workspace = true }
regex = { workspace = true }
deno_core = { workspace = true, optional = true }
deno_error = { workspace = true, optional = true }
url = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
@@ -27,3 +29,5 @@ tokio = { workspace = true, features = ["test-util", "process"] }
tokio-util = "0.7"
wfe-core = { workspace = true, features = ["test-support"] }
wfe = { path = "../wfe" }
wiremock = { workspace = true }
tempfile = { workspace = true }

View File

@@ -1,3 +1,14 @@
globalThis.inputs = () => Deno.core.ops.op_inputs();
globalThis.output = (key, value) => Deno.core.ops.op_output(key, value);
globalThis.log = (msg) => Deno.core.ops.op_log(msg);
globalThis.fetch = async (url, options) => {
const resp = await Deno.core.ops.op_fetch(url, options || null);
return {
status: resp.status,
ok: resp.ok,
headers: resp.headers,
text: () => Promise.resolve(resp.body),
json: () => Promise.resolve(JSON.parse(resp.body)),
};
};

View File

@@ -1,4 +1,5 @@
pub mod config;
pub mod module_loader;
pub mod ops;
pub mod permissions;
pub mod runtime;

View File

@@ -0,0 +1,391 @@
use std::cell::RefCell;
use std::rc::Rc;
use deno_core::{
ModuleLoadOptions, ModuleLoadReferrer, ModuleLoadResponse, ModuleLoader, ModuleSource,
ModuleSourceCode, ModuleSpecifier, ModuleType, ResolutionKind,
};
use deno_error::JsErrorBox;
use super::permissions::PermissionChecker;
/// Custom module loader that enforces permissions and rewrites npm: specifiers.
pub struct WfeModuleLoader {
permissions: Rc<RefCell<PermissionChecker>>,
}
impl WfeModuleLoader {
pub fn new(permissions: Rc<RefCell<PermissionChecker>>) -> Self {
Self { permissions }
}
/// Rewrite `npm:package@version` to `https://esm.sh/package@version`.
pub fn resolve_npm_specifier(specifier: &str) -> Option<String> {
specifier
.strip_prefix("npm:")
.map(|rest| format!("https://esm.sh/{rest}"))
}
}
impl ModuleLoader for WfeModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
kind: ResolutionKind,
) -> Result<ModuleSpecifier, JsErrorBox> {
// Block dynamic imports if not allowed.
if matches!(kind, ResolutionKind::DynamicImport) {
let checker = self.permissions.borrow();
if !checker.allow_dynamic_import {
return Err(JsErrorBox::generic(
"Dynamic import is not allowed by permissions",
));
}
}
// ext: URLs are handled by deno_core.
if specifier.starts_with("ext:") {
return ModuleSpecifier::parse(specifier)
.map_err(|e| JsErrorBox::generic(format!("Invalid ext URL: {e}")));
}
// npm: specifier -> rewrite to esm.sh URL.
if let Some(esm_url) = Self::resolve_npm_specifier(specifier) {
let parsed = ModuleSpecifier::parse(&esm_url)
.map_err(|e| JsErrorBox::generic(format!("Invalid esm.sh URL: {e}")))?;
return Ok(parsed);
}
// wfe: scheme for inline modules (used by load_main_es_module_from_code).
if specifier.starts_with("wfe:") {
return ModuleSpecifier::parse(specifier)
.map_err(|e| JsErrorBox::generic(format!("Invalid wfe URL: {e}")));
}
// Absolute file:// URL.
if specifier.starts_with("file://") {
let parsed = ModuleSpecifier::parse(specifier)
.map_err(|e| JsErrorBox::generic(format!("Invalid file URL: {e}")))?;
let path = parsed
.to_file_path()
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
let checker = self.permissions.borrow();
checker
.check_read(
path.to_str()
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
)
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
return Ok(parsed);
}
// Absolute https:// URL.
if specifier.starts_with("https://") || specifier.starts_with("http://") {
let parsed = ModuleSpecifier::parse(specifier)
.map_err(|e| JsErrorBox::generic(format!("Invalid URL: {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
let checker = self.permissions.borrow();
checker
.check_net(host)
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
return Ok(parsed);
}
// Relative or bare path — resolve against referrer.
// This handles ./foo, ../foo, and /foo (absolute path on same origin, e.g. esm.sh redirects)
if specifier.starts_with("./")
|| specifier.starts_with("../")
|| specifier.starts_with('/')
{
let base = ModuleSpecifier::parse(referrer)
.map_err(|e| JsErrorBox::generic(format!("Invalid referrer '{referrer}': {e}")))?;
let resolved = base
.join(specifier)
.map_err(|e| JsErrorBox::generic(format!("Failed to resolve '{specifier}': {e}")))?;
// Check permissions based on scheme.
match resolved.scheme() {
"file" => {
let path = resolved
.to_file_path()
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
let checker = self.permissions.borrow();
checker
.check_read(
path.to_str()
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
)
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
}
"https" | "http" => {
let host = resolved
.host_str()
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
let checker = self.permissions.borrow();
checker
.check_net(host)
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
}
_ => {}
}
return Ok(resolved);
}
Err(JsErrorBox::generic(format!(
"Unsupported module specifier: '{specifier}'"
)))
}
fn load(
&self,
module_specifier: &ModuleSpecifier,
_maybe_referrer: Option<&ModuleLoadReferrer>,
_options: ModuleLoadOptions,
) -> ModuleLoadResponse {
let specifier = module_specifier.clone();
let permissions = self.permissions.clone();
match specifier.scheme() {
"ext" => {
// Handled by deno_core; should not reach here.
ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
"ext: modules are handled by deno_core",
)))
}
"https" | "http" => {
// Fetch over network.
let url = specifier.to_string();
ModuleLoadResponse::Async(Box::pin(async move {
// Permission check on the host.
let host = specifier
.host_str()
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?
.to_string();
{
let checker = permissions.borrow();
checker
.check_net(&host)
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
}
let response = reqwest::get(&url)
.await
.map_err(|e| {
JsErrorBox::generic(format!("Failed to fetch module '{url}': {e}"))
})?;
if !response.status().is_success() {
return Err(JsErrorBox::generic(format!(
"Failed to fetch module '{url}': HTTP {}",
response.status()
)));
}
let code: String = response.text().await.map_err(|e| {
JsErrorBox::generic(format!("Failed to read module body '{url}': {e}"))
})?;
Ok(ModuleSource::new(
ModuleType::JavaScript,
ModuleSourceCode::String(code.into()),
&specifier,
None,
))
}))
}
"file" => {
// Read from disk.
let path_result = specifier
.to_file_path()
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"));
match path_result {
Ok(path) => {
let path_str = path.to_string_lossy().to_string();
// Permission check.
{
let checker = permissions.borrow();
if let Err(e) = checker.check_read(&path_str) {
return ModuleLoadResponse::Sync(Err(JsErrorBox::new(
"PermissionError",
e.to_string(),
)));
}
}
match std::fs::read_to_string(&path) {
Ok(code) => ModuleLoadResponse::Sync(Ok(ModuleSource::new(
ModuleType::JavaScript,
ModuleSourceCode::String(code.into()),
&specifier,
None,
))),
Err(e) => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
format!("Failed to read module '{}': {e}", path.display()),
))),
}
}
Err(e) => ModuleLoadResponse::Sync(Err(e)),
}
}
scheme => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(format!(
"Unsupported module scheme: '{scheme}'"
)))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executors::deno::config::DenoPermissions;
fn make_loader(perms: DenoPermissions) -> WfeModuleLoader {
let checker = PermissionChecker::from_config(&perms);
WfeModuleLoader::new(Rc::new(RefCell::new(checker)))
}
#[test]
fn resolve_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()));
}
#[test]
fn resolve_npm_scoped_package() {
let result = WfeModuleLoader::resolve_npm_specifier("npm:@org/pkg@1.0.0");
assert_eq!(result, Some("https://esm.sh/@org/pkg@1.0.0".to_string()));
}
#[test]
fn resolve_non_npm_returns_none() {
assert!(WfeModuleLoader::resolve_npm_specifier("https://example.com/mod.js").is_none());
assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none());
}
#[test]
fn resolve_npm_specifier_via_loader() {
let loader = make_loader(DenoPermissions {
net: vec!["esm.sh".to_string()],
..Default::default()
});
let result = loader
.resolve("npm:lodash@4", "ext:wfe/bootstrap.js", ResolutionKind::Import)
.unwrap();
assert_eq!(result.as_str(), "https://esm.sh/lodash@4");
}
#[test]
fn resolve_https_with_permission() {
let loader = make_loader(DenoPermissions {
net: vec!["cdn.example.com".to_string()],
..Default::default()
});
let result = loader
.resolve(
"https://cdn.example.com/mod.js",
"ext:wfe/bootstrap.js",
ResolutionKind::Import,
)
.unwrap();
assert_eq!(result.as_str(), "https://cdn.example.com/mod.js");
}
#[test]
fn resolve_https_without_permission_fails() {
let loader = make_loader(DenoPermissions::default());
let result = loader.resolve(
"https://evil.com/mod.js",
"ext:wfe/bootstrap.js",
ResolutionKind::Import,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Permission denied"));
}
#[test]
fn resolve_dynamic_import_denied() {
let loader = make_loader(DenoPermissions {
net: vec!["cdn.example.com".to_string()],
dynamic_import: false,
..Default::default()
});
let result = loader.resolve(
"https://cdn.example.com/mod.js",
"ext:wfe/bootstrap.js",
ResolutionKind::DynamicImport,
);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Dynamic import is not allowed"));
}
#[test]
fn resolve_dynamic_import_allowed() {
let loader = make_loader(DenoPermissions {
net: vec!["cdn.example.com".to_string()],
dynamic_import: true,
..Default::default()
});
let result = loader.resolve(
"https://cdn.example.com/mod.js",
"ext:wfe/bootstrap.js",
ResolutionKind::DynamicImport,
);
assert!(result.is_ok());
}
#[test]
fn resolve_ext_url() {
let loader = make_loader(DenoPermissions::default());
let result = loader
.resolve(
"ext:wfe/bootstrap.js",
"ext:wfe/bootstrap.js",
ResolutionKind::Import,
)
.unwrap();
assert_eq!(result.as_str(), "ext:wfe/bootstrap.js");
}
#[test]
fn resolve_relative_from_file_referrer() {
let loader = make_loader(DenoPermissions {
read: vec!["/tmp".to_string()],
..Default::default()
});
let result = loader
.resolve(
"./helper.js",
"file:///tmp/main.js",
ResolutionKind::Import,
)
.unwrap();
assert_eq!(result.as_str(), "file:///tmp/helper.js");
}
#[test]
fn resolve_relative_without_read_permission_fails() {
let loader = make_loader(DenoPermissions::default());
let result = loader.resolve("./helper.js", "file:///tmp/main.js", ResolutionKind::Import);
assert!(result.is_err());
}
#[test]
fn resolve_unsupported_specifier() {
let loader = make_loader(DenoPermissions::default());
let result = loader.resolve(
"ftp://bad.com/mod.js",
"ext:wfe/bootstrap.js",
ResolutionKind::Import,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unsupported"));
}
}

View File

@@ -0,0 +1,108 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use deno_core::op2;
use deno_core::OpState;
use deno_error::JsErrorBox;
use serde::{Deserialize, Serialize};
use crate::executors::deno::permissions::PermissionChecker;
/// Options for the fetch call (method, headers, body).
#[derive(Deserialize, Default)]
pub struct FetchOptions {
pub method: Option<String>,
pub headers: Option<HashMap<String, String>>,
pub body: Option<String>,
}
/// Response returned to JavaScript from fetch.
#[derive(Serialize)]
pub struct FetchResponse {
pub status: u16,
pub ok: bool,
pub headers: HashMap<String, String>,
pub body: String,
}
#[op2]
#[serde]
pub async fn op_fetch(
state: Rc<RefCell<OpState>>,
#[string] url: String,
#[serde] options: Option<FetchOptions>,
) -> Result<FetchResponse, JsErrorBox> {
// 1. Parse URL to extract host for permission check.
let parsed = url::Url::parse(&url)
.map_err(|e| JsErrorBox::generic(format!("Invalid URL '{url}': {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| JsErrorBox::generic(format!("URL '{url}' has no host")))?
.to_string();
// 2. Check net permission.
{
let state = state.borrow();
let checker = state.borrow::<PermissionChecker>();
checker
.check_net(&host)
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
}
// 3. Build the request.
let opts = options.unwrap_or_default();
let method = opts.method.as_deref().unwrap_or("GET");
let client = reqwest::Client::new();
let mut builder = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
other => {
return Err(JsErrorBox::generic(format!(
"Unsupported HTTP method: {other}"
)));
}
};
if let Some(ref headers) = opts.headers {
for (key, value) in headers {
builder = builder.header(key.as_str(), value.as_str());
}
}
if let Some(ref body) = opts.body {
builder = builder.body(body.clone());
}
// 4. Execute request.
let response = builder
.send()
.await
.map_err(|e| JsErrorBox::generic(format!("Fetch failed for '{url}': {e}")))?;
// 5. Build response.
let status = response.status().as_u16();
let ok = response.status().is_success();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = response
.text()
.await
.map_err(|e| JsErrorBox::generic(format!("Failed to read response body: {e}")))?;
Ok(FetchResponse {
status,
ok,
headers,
body,
})
}

View File

@@ -1,3 +1,4 @@
pub mod http;
pub mod workflow;
pub use workflow::{StepMeta, StepOutputs, WorkflowInputs};

View File

@@ -46,7 +46,7 @@ pub fn op_log(state: &mut OpState, #[string] msg: String) {
deno_core::extension!(
wfe_ops,
ops = [op_inputs, op_output, op_log],
ops = [op_inputs, op_output, op_log, super::http::op_fetch],
esm_entry_point = "ext:wfe/bootstrap.js",
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
);

View File

@@ -1,10 +1,13 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use deno_core::JsRuntime;
use deno_core::RuntimeOptions;
use wfe_core::WfeError;
use super::config::DenoConfig;
use super::module_loader::WfeModuleLoader;
use super::ops::workflow::{wfe_ops, StepMeta, StepOutputs, WorkflowInputs};
use super::permissions::PermissionChecker;
@@ -16,8 +19,18 @@ pub fn create_runtime(
) -> Result<JsRuntime, WfeError> {
let ext = wfe_ops::init();
// Build permissions, auto-adding esm.sh if modules are declared.
let mut permissions = config.permissions.clone();
if !config.modules.is_empty() && !permissions.net.iter().any(|h| h == "esm.sh") {
permissions.net.push("esm.sh".to_string());
}
let checker = Rc::new(RefCell::new(PermissionChecker::from_config(&permissions)));
let module_loader = WfeModuleLoader::new(checker.clone());
let runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![ext],
module_loader: Some(Rc::new(module_loader)),
..Default::default()
});
@@ -34,12 +47,18 @@ pub fn create_runtime(
state.put(StepMeta {
name: step_name.to_string(),
});
state.put(PermissionChecker::from_config(&config.permissions));
state.put(PermissionChecker::from_config(&permissions));
}
Ok(runtime)
}
/// Returns whether esm.sh would be auto-added for the given config.
/// Exposed for testing.
pub fn would_auto_add_esm_sh(config: &DenoConfig) -> bool {
!config.modules.is_empty() && !config.permissions.net.iter().any(|h| h == "esm.sh")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -78,4 +97,46 @@ mod tests {
let meta = state.borrow::<StepMeta>();
assert_eq!(meta.name, "my-step");
}
#[test]
fn auto_add_esm_sh_when_modules_declared() {
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));
}
#[test]
fn no_auto_add_esm_sh_when_no_modules() {
let config = 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));
}
#[test]
fn no_auto_add_esm_sh_when_already_present() {
let config = 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));
}
}

View File

@@ -51,6 +51,8 @@ impl StepBody for DenoStep {
let config = self.config.clone();
let timeout_ms = self.config.timeout_ms;
let use_module = needs_module_evaluation(&source);
let file_path = self.config.file.clone();
// JsRuntime is !Send, so we run it on a dedicated thread with its own
// single-threaded tokio runtime.
@@ -63,8 +65,19 @@ impl StepBody for DenoStep {
})?;
rt.block_on(async move {
run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms)
if use_module {
run_module_inner(
&config,
workflow_data,
&step_name,
&source,
file_path.as_deref(),
timeout_ms,
)
.await
} else {
run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms).await
}
})
});
@@ -79,6 +92,13 @@ impl StepBody for DenoStep {
}
}
/// Check if the source code uses ES module syntax or top-level await.
fn needs_module_evaluation(source: &str) -> bool {
// Top-level await requires module evaluation. ES import/export also require it.
source.contains("import ") || source.contains("import(") || source.contains("export ")
|| source.contains("await ")
}
async fn run_script_inner(
config: &DenoConfig,
workflow_data: serde_json::Value,
@@ -130,7 +150,100 @@ async fn run_script_inner(
}
})?;
// Extract outputs from OpState.
extract_outputs(&mut runtime)
}
async fn run_module_inner(
config: &DenoConfig,
workflow_data: serde_json::Value,
step_name: &str,
source: &str,
file_path: Option<&str>,
timeout_ms: Option<u64>,
) -> wfe_core::Result<ExecutionResult> {
let mut runtime = create_runtime(config, workflow_data, step_name)?;
let _timeout_guard = timeout_ms.map(|ms| {
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
let duration = std::time::Duration::from_millis(ms);
std::thread::spawn(move || {
std::thread::sleep(duration);
isolate_handle.terminate_execution();
})
});
// Determine the module URL. Use the file path if available, otherwise a synthetic URL.
let module_url = if let Some(path) = file_path {
let abs = std::path::Path::new(path);
let abs = if abs.is_absolute() {
abs.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| WfeError::StepExecution(format!("Cannot get cwd: {e}")))?
.join(abs)
};
url::Url::from_file_path(&abs)
.map_err(|_| {
WfeError::StepExecution(format!("Cannot convert path to URL: {}", abs.display()))
})?
.to_string()
} else {
"wfe:///inline-module.js".to_string()
};
let specifier = deno_core::ModuleSpecifier::parse(&module_url).map_err(|e| {
WfeError::StepExecution(format!("Invalid module URL '{module_url}': {e}"))
})?;
let module_id = runtime
.load_main_es_module_from_code(&specifier, source.to_string())
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("terminated") {
WfeError::StepExecution(format!(
"Deno script timed out after {}ms",
timeout_ms.unwrap_or(0)
))
} else {
WfeError::StepExecution(format!("Deno module load error: {e}"))
}
})?;
let eval_future = runtime.mod_evaluate(module_id);
// Drive the event loop to resolve imports and execute the module.
runtime
.run_event_loop(Default::default())
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("terminated") {
WfeError::StepExecution(format!(
"Deno script timed out after {}ms",
timeout_ms.unwrap_or(0)
))
} else {
WfeError::StepExecution(format!("Deno event loop error: {e}"))
}
})?;
eval_future.await.map_err(|e| {
let msg = e.to_string();
if msg.contains("terminated") {
WfeError::StepExecution(format!(
"Deno script timed out after {}ms",
timeout_ms.unwrap_or(0)
))
} else {
WfeError::StepExecution(format!("Deno module error: {e}"))
}
})?;
extract_outputs(&mut runtime)
}
fn extract_outputs(runtime: &mut deno_core::JsRuntime) -> wfe_core::Result<ExecutionResult> {
let outputs = {
let state = runtime.op_state();
let mut state = state.borrow_mut();

View File

@@ -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));
}