folk-plugin-http 0.3.1

HTTP plugin for Folk — accepts connections via hyper and dispatches to PHP workers
Documentation
//! Unit tests for HookEngine: compile + run Lua scripts, short-circuit,
//! mutation, fail_open / fail_closed behaviour.

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

// ── Helpers ───────────────────────────────────────────────────────────────────

fn tmp_lua(source: &str) -> PathBuf {
    let mut f = tempfile::NamedTempFile::new().unwrap();
    write!(f, "{source}").unwrap();
    let path = f.path().to_path_buf();
    // Keep the file alive by leaking it into a Box — it will be cleaned up
    // when the process exits, which is fine for tests.
    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("hello".to_string()),
        short_circuited: false,
    }
}

// ── HookEngine::new ───────────────────────────────────────────────────────────

#[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() {
    // The script file doesn't exist → compile error → hook is skipped.
    let cfg = hook_cfg("request.before", PathBuf::from("/nonexistent/file.lua"));
    let engine = HookEngine::new(&[cfg]);
    // Engine starts fine; the hook is just absent.
    let mut ctx = default_req_ctx();
    let result = engine.run_request_before(&mut ctx);
    assert!(matches!(result, HookResult::Continue));
}

// ── Pass-through (nil return) ─────────────────────────────────────────────────

#[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));
}

// ── Short-circuit ─────────────────────────────────────────────────────────────

#[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");
    }
}

// ── Header mutation ───────────────────────────────────────────────────────────

#[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"));
}

// ── Response hooks ────────────────────────────────────────────────────────────

#[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("mutated"));
}

#[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");
    }
}

// ── fail_open / fail_closed ───────────────────────────────────────────────────

#[test]
fn fail_open_on_lua_error() {
    // Script has a Lua runtime error (index nil value).
    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();
    // fail_open → continue despite the error
    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();
    // fail_closed → 500 short-circuit
    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");
    }
}

// ── has_event ─────────────────────────────────────────────────────────────────

#[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() {
    // Script fails to compile → hook is skipped → has_event must return false.
    let cfg = hook_cfg("response.after", PathBuf::from("/nonexistent/hook.lua"));
    let engine = HookEngine::new(&[cfg]);
    assert!(!engine.has_event("response.after"));
}

// ── Binary body not corrupted with non-response.after hooks ──────────────────

#[test]
fn response_after_body_none_when_no_response_after_hooks() {
    // Engine has only request.before hook — resp_ctx.body must stay None
    // so that the caller falls back to the original raw body_bytes.
    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, // caller leaves body None when has_event("response.after") is false
        short_circuited: false,
    };
    let result = engine.run_response_after(&mut ctx);
    assert!(matches!(result, HookResult::Continue));
    // body must still be None — the hook engine must not set it
    assert!(ctx.body.is_none());
}

// ── Unknown event is silently ignored ────────────────────────────────────────

#[test]
fn unknown_event_ignored() {
    let script = tmp_lua(r#"return { status = 403, body = "nope" }"#);
    // Hook registered for an unknown event should never fire.
    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));
}

// ── Conditional short-circuit ─────────────────────────────────────────────────

#[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)]);

    // Missing key → 401
    let mut ctx = default_req_ctx();
    let result = engine.run_request_before(&mut ctx);
    assert!(matches!(result, HookResult::ShortCircuit(_)));

    // Correct key → continue (need new engine + script since bytecode is reusable)
    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));
}

// ── Multiple sync hooks run in order ─────────────────────────────────────────

#[test]
fn multiple_sync_hooks_run_in_order() {
    // Hook 1 sets extra["step"] = "1"; Hook 2 appends "+2"
    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"));
}

// ── Short-circuit stops subsequent hooks ──────────────────────────────────────

#[test]
fn short_circuit_stops_subsequent_sync_hooks() {
    // Hook 1 short-circuits; hook 2 would set extra["second"] = "yes"
    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(_)));
    // Hook 2 should NOT have run.
    assert!(ctx.extra.get("second").is_none());
}

// ── Async hooks do not block ──────────────────────────────────────────────────

#[tokio::test]
async fn async_hook_does_not_block() {
    // Async hook with a slow script should not block the caller.
    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);
    // Async hook → always Continue from the caller's perspective.
    assert!(matches!(result, HookResult::Continue));
}