use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use folk_plugin_http::config::{HookConfig, HookErrorBehavior, HookMode};
use folk_plugin_http::hooks::{HookEngine, HookResult, RequestContext, ResponseContext};
fn tmp_lua(source: &str) -> PathBuf {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "{source}").unwrap();
let path = f.path().to_path_buf();
std::mem::forget(f);
path
}
fn hook_cfg(event: &str, lua: PathBuf) -> HookConfig {
HookConfig {
event: event.to_string(),
lua,
mode: HookMode::Sync,
timeout_ms: 100,
on_error: HookErrorBehavior::FailOpen,
}
}
fn hook_cfg_with(
event: &str,
lua: PathBuf,
mode: HookMode,
on_error: HookErrorBehavior,
) -> HookConfig {
HookConfig {
event: event.to_string(),
lua,
mode,
timeout_ms: 100,
on_error,
}
}
fn default_req_ctx() -> RequestContext {
RequestContext {
method: "GET".to_string(),
path: "/".to_string(),
query: String::new(),
client_ip: "127.0.0.1".to_string(),
request_id: String::new(),
headers: HashMap::new(),
extra: HashMap::new(),
error: None,
short_circuited: false,
}
}
fn default_resp_ctx() -> ResponseContext {
ResponseContext {
status: 200,
resp_headers: HashMap::new(),
body: Some(b"hello".to_vec()),
short_circuited: false,
}
}
#[test]
fn engine_empty_hooks() {
let engine = HookEngine::new(&[]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn engine_skips_bad_script() {
let cfg = hook_cfg("request.before", PathBuf::from("/nonexistent/file.lua"));
let engine = HookEngine::new(&[cfg]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn hook_passthrough_nil() {
let script = tmp_lua("return nil");
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn hook_passthrough_empty_return() {
let script = tmp_lua("-- just a comment, no return");
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn hook_short_circuits_with_status() {
let script = tmp_lua(r#"return { status = 429, body = "Too Many Requests" }"#);
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::ShortCircuit(_)));
assert!(ctx.short_circuited);
if let HookResult::ShortCircuit(resp) = result {
assert_eq!(resp.status().as_u16(), 429);
}
}
#[test]
fn hook_short_circuit_401() {
let script = tmp_lua(r#"return { status = 401, body = "Unauthorized" }"#);
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
if let HookResult::ShortCircuit(resp) = result {
assert_eq!(resp.status().as_u16(), 401);
} else {
panic!("expected ShortCircuit");
}
}
#[test]
fn hook_mutates_headers() {
let script = tmp_lua(r#"ctx.headers["x-hook"] = "injected""#);
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(
ctx.headers.get("x-hook").map(|s| s.as_str()),
Some("injected")
);
}
#[test]
fn hook_mutates_extra() {
let script = tmp_lua(r#"ctx.extra["tenant"] = "acme""#);
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(ctx.extra.get("tenant").map(|s| s.as_str()), Some("acme"));
}
#[test]
fn response_headers_hook_mutates() {
let script = tmp_lua(r#"ctx.resp_headers["access-control-allow-origin"] = "*""#);
let engine = HookEngine::new(&[hook_cfg("response.headers", script)]);
let mut ctx = ResponseContext {
status: 200,
resp_headers: HashMap::new(),
body: None,
short_circuited: false,
};
let result = engine.run_response_headers(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(
ctx.resp_headers
.get("access-control-allow-origin")
.map(|s| s.as_str()),
Some("*")
);
}
#[test]
fn response_after_hook_mutates_body() {
let script = tmp_lua(r#"ctx.body = "mutated""#);
let engine = HookEngine::new(&[hook_cfg("response.after", script)]);
let mut ctx = default_resp_ctx();
let result = engine.run_response_after(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(ctx.body.as_deref(), Some(b"mutated" as &[u8]));
}
#[test]
fn binary_body_preserved_through_response_after_hook() {
let binary: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0xFE, 0x80];
let script = tmp_lua("-- no-op");
let engine = HookEngine::new(&[hook_cfg("response.after", script)]);
let mut ctx = ResponseContext {
status: 200,
resp_headers: HashMap::new(),
body: Some(binary.clone()),
short_circuited: false,
};
let result = engine.run_response_after(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(ctx.body.as_deref(), Some(binary.as_slice()));
}
#[test]
fn response_after_hook_short_circuits() {
let script = tmp_lua(r#"return { status = 503, body = "down" }"#);
let engine = HookEngine::new(&[hook_cfg("response.after", script)]);
let mut ctx = default_resp_ctx();
let result = engine.run_response_after(&mut ctx);
if let HookResult::ShortCircuit(resp) = result {
assert_eq!(resp.status().as_u16(), 503);
} else {
panic!("expected ShortCircuit");
}
}
#[test]
fn fail_open_on_lua_error() {
let script = tmp_lua(r#"local x = nil; return x.foo"#);
let cfg = hook_cfg_with(
"request.before",
script,
HookMode::Sync,
HookErrorBehavior::FailOpen,
);
let engine = HookEngine::new(&[cfg]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn fail_closed_on_lua_error() {
let script = tmp_lua(r#"local x = nil; return x.foo"#);
let cfg = hook_cfg_with(
"request.before",
script,
HookMode::Sync,
HookErrorBehavior::FailClosed,
);
let engine = HookEngine::new(&[cfg]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
if let HookResult::ShortCircuit(resp) = result {
assert_eq!(resp.status().as_u16(), 500);
} else {
panic!("expected ShortCircuit(500) for fail_closed");
}
}
#[test]
fn has_event_true_for_registered_event() {
let script = tmp_lua("return nil");
let engine = HookEngine::new(&[hook_cfg("response.after", script)]);
assert!(engine.has_event("response.after"));
assert!(!engine.has_event("request.before"));
}
#[test]
fn has_event_false_when_empty() {
let engine = HookEngine::new(&[]);
assert!(!engine.has_event("response.after"));
}
#[test]
fn has_event_false_after_compile_failure() {
let cfg = hook_cfg("response.after", PathBuf::from("/nonexistent/hook.lua"));
let engine = HookEngine::new(&[cfg]);
assert!(!engine.has_event("response.after"));
}
#[test]
fn response_after_body_none_when_no_response_after_hooks() {
let script = tmp_lua("return nil");
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = ResponseContext {
status: 200,
resp_headers: HashMap::new(),
body: None, short_circuited: false,
};
let result = engine.run_response_after(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert!(ctx.body.is_none());
}
#[test]
fn unknown_event_ignored() {
let script = tmp_lua(r#"return { status = 403, body = "nope" }"#);
let engine = HookEngine::new(&[hook_cfg("unknown.event", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}
#[test]
fn conditional_short_circuit_on_header() {
let script = tmp_lua(
r#"
if ctx.headers["x-api-key"] ~= "secret" then
return { status = 401, body = "bad key" }
end
"#,
);
let engine = HookEngine::new(&[hook_cfg("request.before", script)]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::ShortCircuit(_)));
let script2 = tmp_lua(
r#"
if ctx.headers["x-api-key"] ~= "secret" then
return { status = 401, body = "bad key" }
end
"#,
);
let engine2 = HookEngine::new(&[hook_cfg("request.before", script2)]);
let mut ctx2 = default_req_ctx();
ctx2.headers
.insert("x-api-key".to_string(), "secret".to_string());
let result2 = engine2.run_request_before(&mut ctx2);
assert!(matches!(result2, HookResult::Continue));
}
#[test]
fn multiple_sync_hooks_run_in_order() {
let script1 = tmp_lua(r#"ctx.extra["step"] = "1""#);
let script2 = tmp_lua(r#"ctx.extra["step"] = ctx.extra["step"] .. "+2""#);
let engine = HookEngine::new(&[
hook_cfg("request.before", script1),
hook_cfg("request.before", script2),
]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
assert_eq!(ctx.extra.get("step").map(|s| s.as_str()), Some("1+2"));
}
#[test]
fn short_circuit_stops_subsequent_sync_hooks() {
let script1 = tmp_lua(r#"return { status = 403, body = "stop" }"#);
let script2 = tmp_lua(r#"ctx.extra["second"] = "yes""#);
let engine = HookEngine::new(&[
hook_cfg("request.before", script1),
hook_cfg("request.before", script2),
]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::ShortCircuit(_)));
assert!(ctx.extra.get("second").is_none());
}
#[tokio::test]
async fn async_hook_does_not_block() {
let script = tmp_lua("return nil");
let cfg = hook_cfg_with(
"request.before",
script,
HookMode::Async,
HookErrorBehavior::FailOpen,
);
let engine = HookEngine::new(&[cfg]);
let mut ctx = default_req_ctx();
let result = engine.run_request_before(&mut ctx);
assert!(matches!(result, HookResult::Continue));
}