summaryrefslogtreecommitdiff
path: root/internal/plugin/engine_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/plugin/engine_test.go')
-rw-r--r--internal/plugin/engine_test.go1003
1 files changed, 1003 insertions, 0 deletions
diff --git a/internal/plugin/engine_test.go b/internal/plugin/engine_test.go
new file mode 100644
index 0000000..33a4f8a
--- /dev/null
+++ b/internal/plugin/engine_test.go
@@ -0,0 +1,1003 @@
+package plugin_test
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/plugin"
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+func pluginDir(t *testing.T, scripts map[string]string) string {
+ t.Helper()
+ dir := t.TempDir()
+ for name, src := range scripts {
+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0644))
+ }
+ return dir
+}
+
+func newTestStore(t *testing.T) *store.Store {
+ t.Helper()
+ f, err := os.CreateTemp("", "shiv-plugin-engine-*.shiv")
+ require.NoError(t, err)
+ f.Close()
+ t.Cleanup(func() { os.Remove(f.Name()) })
+ st, err := store.Open(f.Name())
+ require.NoError(t, err)
+ t.Cleanup(func() { st.Close() })
+ return st
+}
+
+// newEngineWithBus creates an Engine using a caller-supplied bus so tests can
+// register custom observers (e.g. to capture log events).
+func newEngineWithBus(t *testing.T, st *store.Store, bus *events.Bus, scripts map[string]string) *plugin.Engine {
+ t.Helper()
+ dir := pluginDir(t, scripts)
+ e, err := plugin.NewEngine(dir, st, bus)
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+ return e
+}
+
+// newEngine creates an Engine with a no-op bus — sufficient for tests that do
+// not need to observe log or enable events.
+func newEngine(t *testing.T, st *store.Store, scripts map[string]string) *plugin.Engine {
+ t.Helper()
+ return newEngineWithBus(t, st, events.NewBus(), scripts)
+}
+
+func requestEvent(t *testing.T, method, rawURL string, body []byte) events.RequestEvent {
+ t.Helper()
+ return events.RequestEvent{
+ Request: httptest.NewRequest(method, rawURL, nil),
+ Body: body,
+ }
+}
+
+func responseEvent() events.ResponseEvent {
+ return events.ResponseEvent{
+ Timestamp: time.Now(),
+ Host: "example.com",
+ Proto: "HTTP/1.1",
+ Method: "GET",
+ URL: "http://example.com/path",
+ ReqHeaders: http.Header{"X-Req": []string{"req-val"}},
+ ReqBody: []byte("req body"),
+ StatusCode: 200,
+ RespHeaders: http.Header{"X-Resp": []string{"resp-val"}},
+ RespBody: []byte("resp body"),
+ DurationMs: 55,
+ TLS: true,
+ }
+}
+
+func wsFrameEvent(connID uint64, payload []byte) events.WebSocketFrameEvent {
+ return events.WebSocketFrameEvent{
+ ConnectionID: connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: payload,
+ }
+}
+
+// ── NewEngine ─────────────────────────────────────────────────────────────────
+
+func TestNewEngine_EmptyDir_ReturnsEngine(t *testing.T) {
+ st := newTestStore(t)
+ e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus())
+ require.NoError(t, err)
+ assert.NotNil(t, e)
+ e.Close()
+}
+
+func TestNewEngine_NonExistentDir_ReturnsEngine(t *testing.T) {
+ st := newTestStore(t)
+ e, err := plugin.NewEngine("/does/not/exist", st, events.NewBus())
+ require.NoError(t, err)
+ assert.NotNil(t, e)
+ e.Close()
+}
+
+func TestNewEngine_InvalidScript_SkippedEngineStillCreated(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "bad.lua": `%%% not lua`,
+ "good.lua": `x = 1`,
+ })
+ assert.NotPanics(t, func() {
+ e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ })
+}
+
+func TestNewEngine_RegistersPluginsInStore(t *testing.T) {
+ st := newTestStore(t)
+ newEngine(t, st, map[string]string{
+ "a.lua": `x = 1`,
+ "b.lua": `y = 2`,
+ })
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ assert.Len(t, entries, 2)
+}
+
+func TestNewEngine_RestoresEnabledStateFromStore(t *testing.T) {
+ st := newTestStore(t)
+ dir := pluginDir(t, map[string]string{
+ "test.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+
+ // First load — plugin is enabled by default and drops requests.
+ e1, err := plugin.NewEngine(dir, st, events.NewBus())
+ require.NoError(t, err)
+ result := e1.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.True(t, result.Drop)
+ e1.Close()
+
+ // Disable via store directly.
+ st.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+
+ // Second load — must restore disabled state from store.
+ e2, err := plugin.NewEngine(dir, st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e2.Close() })
+ result = e2.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.False(t, result.Drop, "plugin disabled in store must not fire on_request")
+}
+
+func TestNewEngine_ScanRemovesStaleRecords(t *testing.T) {
+ st := newTestStore(t)
+
+ // Manually register a plugin that won't be on disk.
+ require.NoError(t, st.RegisterPlugin("ghost.lua", "/ghost.lua"))
+
+ dir := pluginDir(t, map[string]string{"real.lua": `x = 1`})
+ _, err := plugin.NewEngine(dir, st, events.NewBus())
+ require.NoError(t, err)
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "real.lua", entries[0].Name)
+}
+
+// ── HandleRequest — no hook ───────────────────────────────────────────────────
+
+func TestHandleRequest_NoPlugins_PassThrough(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, nil)
+ ev := requestEvent(t, "GET", "http://example.com/path", []byte("body"))
+ result := e.HandleRequest(ev)
+ assert.False(t, result.Drop)
+ assert.Equal(t, ev.Request, result.Request)
+ assert.Equal(t, ev.Body, result.Body)
+}
+
+func TestHandleRequest_PluginWithNoOnRequest_PassThrough(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{"noop.lua": `-- no hooks`})
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.False(t, result.Drop)
+}
+
+func TestHandleRequest_NilRequest_PassThrough(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "hook.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+ result := e.HandleRequest(events.RequestEvent{Request: nil, Body: nil})
+ assert.False(t, result.Drop)
+}
+
+// ── HandleRequest — drop ──────────────────────────────────────────────────────
+
+func TestHandleRequest_Drop_ReturnsDrop(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "drop.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.True(t, result.Drop)
+}
+
+func TestHandleRequest_FirstPluginDrops_SecondNotCalled(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_drop.lua": `function on_request(r) r.drop = true; return r end`,
+ "2_panic.lua": `function on_request(r) error("must not be called") end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.True(t, result.Drop)
+}
+
+// ── HandleRequest — modifications ─────────────────────────────────────────────
+
+func TestHandleRequest_ModifiesBody(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "body.lua": `function on_request(r) r.body = "injected"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "POST", "http://example.com", []byte("original")))
+ assert.False(t, result.Drop)
+ assert.Equal(t, []byte("injected"), result.Body)
+}
+
+func TestHandleRequest_ModifiesMethod(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "method.lua": `function on_request(r) r.method = "POST"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.Equal(t, "POST", result.Request.Method)
+}
+
+func TestHandleRequest_ModifiesURL(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "url.lua": `function on_request(r) r.url = "http://example.com/modified"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com/original", nil))
+ assert.Equal(t, "/modified", result.Request.URL.Path)
+}
+
+func TestHandleRequest_ModifiesHeaders(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "headers.lua": `function on_request(r) r.headers["X-Plugin"] = "injected"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.Equal(t, "injected", result.Request.Header.Get("X-Plugin"))
+}
+
+func TestHandleRequest_ChainedPlugins_ModificationsAccumulate(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_first.lua": `function on_request(r) r.body = r.body .. "-first"; return r end`,
+ "2_second.lua": `function on_request(r) r.body = r.body .. "-second"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("start")))
+ assert.Equal(t, []byte("start-first-second"), result.Body)
+}
+
+func TestHandleRequest_LuaError_PluginSkipped_ChainContinues(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_error.lua": `function on_request(r) error("oops") end`,
+ "2_ok.lua": `function on_request(r) r.body = "reached"; return r end`,
+ })
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("original")))
+ assert.False(t, result.Drop)
+ assert.Equal(t, []byte("reached"), result.Body)
+}
+
+// ── HandleRequest — enabled flag ──────────────────────────────────────────────
+
+func TestHandleRequest_DisabledPlugin_HookNotCalled(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "drop.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false})
+
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.False(t, result.Drop, "disabled plugin must not fire on_request")
+}
+
+func TestHandleRequest_ReenabledPlugin_HookCalledAgain(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "drop.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false})
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.False(t, result.Drop)
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: true})
+ result = e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.True(t, result.Drop, "re-enabled plugin must fire on_request again")
+}
+
+func TestHandleRequest_DisabledPluginInChain_OthersStillRun(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_disabled.lua": `function on_request(r) r.body = r.body .. "-disabled"; return r end`,
+ "2_enabled.lua": `function on_request(r) r.body = r.body .. "-enabled"; return r end`,
+ })
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "1_disabled.lua", Enabled: false})
+
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("start")))
+ assert.Equal(t, []byte("start-enabled"), result.Body)
+}
+
+// ── ObservePluginEnabled ──────────────────────────────────────────────────────
+
+func TestObservePluginEnabled_UnknownPlugin_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, nil)
+ assert.NotPanics(t, func() {
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "ghost.lua", Enabled: false})
+ })
+}
+
+func TestObservePluginEnabled_TakesEffectImmediately(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "drop.lua": `function on_request(r) r.drop = true; return r end`,
+ })
+
+ // Enabled by default — drops.
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.True(t, result.Drop)
+
+ // Disable — next call must pass through.
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "drop.lua", Enabled: false})
+ result = e.HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+ assert.False(t, result.Drop)
+}
+
+// ── ObserveResponse ───────────────────────────────────────────────────────────
+
+func TestObserveResponse_NoPlugins_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, nil)
+ assert.NotPanics(t, func() { e.ObserveResponse(responseEvent()) })
+}
+
+func TestObserveResponse_ReceivesCorrectFields(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "observe.lua": `
+ function on_response(r)
+ db.loot_add(r.host .. "|" .. r.method .. "|" .. tostring(r.status) .. "|" .. tostring(r.tls), "Info")
+ end
+ `,
+ })
+
+ ev := responseEvent()
+ e.ObserveResponse(ev)
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, fmt.Sprintf("%s|%s|%d|%v", ev.Host, ev.Method, ev.StatusCode, ev.TLS), loot[0].Title)
+}
+
+func TestObserveResponse_MultiplePluginsAllCalled(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "a.lua": `function on_response(r) db.loot_add("a", "Info") end`,
+ "b.lua": `function on_response(r) db.loot_add("b", "Info") end`,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ assert.Len(t, loot, 2)
+}
+
+func TestObserveResponse_LuaError_OtherPluginsContinue(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_error.lua": `function on_response(r) error("fail") end`,
+ "2_ok.lua": `function on_response(r) db.loot_add("ok", "Info") end`,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "ok", loot[0].Title)
+}
+
+func TestObserveResponse_DisabledPlugin_HookNotCalled(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "obs.lua": `function on_response(r) db.loot_add("called", "Info") end`,
+ })
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "obs.lua", Enabled: false})
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ assert.Empty(t, loot, "disabled plugin must not fire on_response")
+}
+
+// ── ObserveWebSocketFrame ─────────────────────────────────────────────────────
+
+func TestObserveWebSocketFrame_NoPlugins_ReturnsOriginalPayload(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, nil)
+ ev := wsFrameEvent(1, []byte("hello"))
+ result := e.ObserveWebSocketFrame(ev)
+ assert.Equal(t, []byte("hello"), result.Payload)
+}
+
+func TestObserveWebSocketFrame_PluginWithNoHook_ReturnsOriginalPayload(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{"noop.lua": `-- no hooks`})
+ result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original")))
+ assert.Equal(t, []byte("original"), result.Payload)
+}
+
+func TestObserveWebSocketFrame_PluginModifiesPayload(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "ws.lua": `
+ function on_websocket_frame(f)
+ f.payload = f.payload .. "-modified"
+ return f
+ end
+ `,
+ })
+ result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("hello")))
+ assert.Equal(t, []byte("hello-modified"), result.Payload)
+}
+
+func TestObserveWebSocketFrame_ChainedPlugins_ModificationsAccumulate(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_first.lua": `function on_websocket_frame(f) f.payload = f.payload .. "-first"; return f end`,
+ "2_second.lua": `function on_websocket_frame(f) f.payload = f.payload .. "-second"; return f end`,
+ })
+ result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("start")))
+ assert.Equal(t, []byte("start-first-second"), result.Payload)
+}
+
+func TestObserveWebSocketFrame_ReceivesCorrectFields(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "ws.lua": `
+ function on_websocket_frame(f)
+ db.loot_add(
+ tostring(f.connection_id) .. "|" .. tostring(f.direction) .. "|" .. tostring(f.opcode),
+ "Info"
+ )
+ return f
+ end
+ `,
+ })
+
+ ev := events.WebSocketFrameEvent{
+ ConnectionID: 42,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketServer,
+ Opcode: events.WebSocketBinary,
+ Payload: []byte("data"),
+ }
+ e.ObserveWebSocketFrame(ev)
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "42|1|2", loot[0].Title)
+}
+
+func TestObserveWebSocketFrame_DisabledPlugin_HookNotCalled(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "ws.lua": `function on_websocket_frame(f) f.payload = "modified"; return f end`,
+ })
+
+ e.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "ws.lua", Enabled: false})
+ result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original")))
+ assert.Equal(t, []byte("original"), result.Payload, "disabled plugin must not modify frame")
+}
+
+func TestObserveWebSocketFrame_LuaError_ReturnsLastGoodPayload(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "1_ok.lua": `function on_websocket_frame(f) f.payload = "good"; return f end`,
+ "2_error.lua": `function on_websocket_frame(f) error("fail") end`,
+ })
+ result := e.ObserveWebSocketFrame(wsFrameEvent(1, []byte("original")))
+ assert.Equal(t, []byte("good"), result.Payload)
+}
+
+// ── ObserveLoadPlugin ─────────────────────────────────────────────────────────
+
+func TestObserveLoadPlugin_LoadsAndRegistersPlugin(t *testing.T) {
+ st := newTestStore(t)
+ destDir := t.TempDir()
+ e, err := plugin.NewEngine(destDir, st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ // Write source file to a separate temp dir.
+ srcDir := t.TempDir()
+ srcPath := filepath.Join(srcDir, "new.lua")
+ require.NoError(t, os.WriteFile(srcPath, []byte(`
+ function on_request(r)
+ r.body = "from-new-plugin"
+ return r
+ end
+ `), 0644))
+
+ e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath})
+
+ // Plugin must be active immediately.
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("original")))
+ assert.Equal(t, []byte("from-new-plugin"), result.Body)
+}
+
+func TestObserveLoadPlugin_CopiesFileToPluginsDir(t *testing.T) {
+ st := newTestStore(t)
+ destDir := t.TempDir()
+ e, err := plugin.NewEngine(destDir, st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ srcPath := filepath.Join(t.TempDir(), "copied.lua")
+ require.NoError(t, os.WriteFile(srcPath, []byte(`x = 1`), 0644))
+
+ e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath})
+
+ _, err = os.Stat(filepath.Join(destDir, "copied.lua"))
+ assert.NoError(t, err, "file must be copied to plugins directory")
+}
+
+func TestObserveLoadPlugin_RegistersInStore(t *testing.T) {
+ st := newTestStore(t)
+ destDir := t.TempDir()
+ e, err := plugin.NewEngine(destDir, st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ srcPath := filepath.Join(t.TempDir(), "registered.lua")
+ require.NoError(t, os.WriteFile(srcPath, []byte(`x = 1`), 0644))
+
+ e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath})
+ time.Sleep(50 * time.Millisecond)
+
+ entries, err := st.AllPlugins()
+ require.NoError(t, err)
+ names := make([]string, 0, len(entries))
+ for _, e := range entries {
+ names = append(names, e.Name)
+ }
+ assert.Contains(t, names, "registered.lua")
+}
+
+func TestObserveLoadPlugin_BadSourcePath_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ assert.NotPanics(t, func() {
+ e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: "/does/not/exist.lua"})
+ })
+}
+
+func TestObserveLoadPlugin_InvalidLua_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ destDir := t.TempDir()
+ e, err := plugin.NewEngine(destDir, st, events.NewBus())
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ srcPath := filepath.Join(t.TempDir(), "bad.lua")
+ require.NoError(t, os.WriteFile(srcPath, []byte(`%%% invalid lua`), 0644))
+
+ assert.NotPanics(t, func() {
+ e.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: srcPath})
+ })
+}
+
+// ── log() routing ─────────────────────────────────────────────────────────────
+
+func TestLog_RoutedThroughBus(t *testing.T) {
+ st := newTestStore(t)
+ bus := events.NewBus()
+
+ var mu sync.Mutex
+ var logLines []string
+ bus.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) {
+ mu.Lock()
+ logLines = append(logLines, e.Name+": "+e.Message)
+ mu.Unlock()
+ }))
+
+ newEngineWithBus(t, st, bus, map[string]string{
+ "logger.lua": `
+ function on_request(r)
+ log("intercepted request")
+ return r
+ end
+ `,
+ })
+
+ newEngineWithBus(t, st, bus, map[string]string{
+ "logger.lua": `
+ function on_request(r)
+ log("intercepted request")
+ return r
+ end
+ `,
+ }).HandleRequest(requestEvent(t, "GET", "http://example.com", nil))
+
+ mu.Lock()
+ found := false
+ for _, l := range logLines {
+ if l == "logger.lua: intercepted request" {
+ found = true
+ break
+ }
+ }
+ mu.Unlock()
+ assert.True(t, found, "log() call must emit PluginLogEvent on the bus")
+}
+
+func TestLog_OnLoad_DrainedAfterNewEngine(t *testing.T) {
+ st := newTestStore(t)
+ bus := events.NewBus()
+
+ var mu sync.Mutex
+ var logLines []string
+ bus.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) {
+ mu.Lock()
+ logLines = append(logLines, e.Message)
+ mu.Unlock()
+ }))
+
+ dir := pluginDir(t, map[string]string{
+ "startup.lua": `
+ function on_load()
+ log("loaded ok")
+ end
+ `,
+ })
+ e, err := plugin.NewEngine(dir, st, bus)
+ require.NoError(t, err)
+ t.Cleanup(func() { e.Close() })
+
+ mu.Lock()
+ lines := append([]string{}, logLines...)
+ mu.Unlock()
+ assert.Contains(t, lines, "loaded ok", "on_load log() calls must be emitted before NewEngine returns")
+}
+
+// ── Engine.Close ──────────────────────────────────────────────────────────────
+
+func TestClose_NoPanic(t *testing.T) {
+ st := newTestStore(t)
+ e, err := plugin.NewEngine(t.TempDir(), st, events.NewBus())
+ require.NoError(t, err)
+ assert.NotPanics(t, func() { e.Close() })
+}
+
+// ── API: log ──────────────────────────────────────────────────────────────────
+
+func TestAPI_Log_IsAvailable(t *testing.T) {
+ st := newTestStore(t)
+ dir := pluginDir(t, map[string]string{
+ "log.lua": `function on_load() log("hello from plugin") end`,
+ })
+ e, err := plugin.NewEngine(dir, st, events.NewBus())
+ require.NoError(t, err)
+ e.Close()
+}
+
+// ── API: http.get ─────────────────────────────────────────────────────────────
+
+func TestAPI_HTTPGet_ReturnsStatusAndBody(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, "pong")
+ }))
+ defer upstream.Close()
+
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "get.lua": fmt.Sprintf(`
+ function on_response(r)
+ local resp = http.get("%s")
+ if resp then db.loot_add(tostring(resp.status) .. "|" .. resp.body, "Info") end
+ end
+ `, upstream.URL),
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(100 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "200|pong", loot[0].Title)
+}
+
+func TestAPI_HTTPGet_BadURL_ReturnsNilAndError(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "get_fail.lua": `
+ function on_response(r)
+ local resp, err = http.get("http://127.0.0.1:1/")
+ if resp == nil and err ~= nil then db.loot_add("error_handled", "Info") end
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(200 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "error_handled", loot[0].Title)
+}
+
+// ── API: http.post ────────────────────────────────────────────────────────────
+
+func TestAPI_HTTPPost_SendsBodyAndReturnsResponse(t *testing.T) {
+ var receivedBody string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ buf := make([]byte, 512)
+ n, _ := r.Body.Read(buf)
+ receivedBody = string(buf[:n])
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprint(w, "created")
+ }))
+ defer upstream.Close()
+
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "post.lua": fmt.Sprintf(`
+ function on_response(r)
+ local resp = http.post("%s", '{"key":"val"}', "application/json")
+ if resp then db.loot_add(tostring(resp.status) .. "|" .. resp.body, "Info") end
+ end
+ `, upstream.URL),
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(100 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "201|created", loot[0].Title)
+ assert.Equal(t, `{"key":"val"}`, receivedBody)
+}
+
+// ── API: http.request ─────────────────────────────────────────────────────────
+
+func TestAPI_HTTPRequest_SendsCustomMethodAndHeaders(t *testing.T) {
+ var receivedMethod, receivedHeader string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedMethod = r.Method
+ receivedHeader = r.Header.Get("X-Custom")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer upstream.Close()
+
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "request.lua": fmt.Sprintf(`
+ function on_response(r)
+ local resp = http.request("DELETE", "%s", "", {["X-Custom"] = "plugin-value"})
+ if resp then db.loot_add(tostring(resp.status), "Info") end
+ end
+ `, upstream.URL),
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(100 * time.Millisecond)
+
+ assert.Equal(t, "DELETE", receivedMethod)
+ assert.Equal(t, "plugin-value", receivedHeader)
+}
+
+// ── API: db.history ───────────────────────────────────────────────────────────
+
+func TestAPI_DBHistory_ReturnsTransactions(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.Log(store.Transaction{
+ Timestamp: time.Now(), Host: "target.com", Method: "GET",
+ URL: "http://target.com/", Proto: "HTTP/1.1", StatusCode: 200, InScope: true,
+ }))
+ time.Sleep(50 * time.Millisecond)
+
+ e := newEngine(t, st, map[string]string{
+ "history.lua": `
+ function on_response(r)
+ local txs = db.history(10)
+ if txs then db.loot_add(tostring(#txs), "Info") end
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "1", loot[0].Title)
+}
+
+func TestAPI_DBHistory_TransactionFieldsExposed(t *testing.T) {
+ st := newTestStore(t)
+ require.NoError(t, st.Log(store.Transaction{
+ Timestamp: time.Now(), Host: "check.com", Method: "POST",
+ URL: "http://check.com/api", Proto: "HTTP/1.1", StatusCode: 201, InScope: true,
+ }))
+ time.Sleep(50 * time.Millisecond)
+
+ e := newEngine(t, st, map[string]string{
+ "fields.lua": `
+ function on_response(r)
+ local txs = db.history(1)
+ if txs and #txs > 0 then
+ local tx = txs[1]
+ db.loot_add(tx.host .. "|" .. tx.method .. "|" .. tostring(tx.status), "Info")
+ end
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "check.com|POST|201", loot[0].Title)
+}
+
+// ── API: db.history_filter ────────────────────────────────────────────────────
+
+func TestAPI_DBHistoryFilter_FiltersByHost(t *testing.T) {
+ st := newTestStore(t)
+ for _, host := range []string{"alpha.com", "beta.com", "alpha.com"} {
+ require.NoError(t, st.Log(store.Transaction{
+ Timestamp: time.Now(), Host: host, Method: "GET",
+ URL: "http://" + host + "/", Proto: "HTTP/1.1", StatusCode: 200, InScope: true,
+ }))
+ }
+ time.Sleep(50 * time.Millisecond)
+
+ e := newEngine(t, st, map[string]string{
+ "filter.lua": `
+ function on_response(r)
+ local txs = db.history_filter("alpha.com")
+ db.loot_add(tostring(#txs), "Info")
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "2", loot[0].Title)
+}
+
+// ── API: db.scope ─────────────────────────────────────────────────────────────
+
+func TestAPI_DBScope_AddGetRemove(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "scope.lua": `
+ function on_response(r)
+ db.scope_add("plugin-scope.com")
+ local entries = db.scope_get()
+ db.loot_add("count:" .. tostring(#entries), "Info")
+ if #entries > 0 then db.scope_remove(entries[1].id) end
+ local after = db.scope_get()
+ db.loot_add("after:" .. tostring(#after), "Info")
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 2)
+ titles := map[string]bool{}
+ for _, l := range loot {
+ titles[l.Title] = true
+ }
+ assert.True(t, titles["count:1"])
+ assert.True(t, titles["after:0"])
+}
+
+// ── API: db.loot ──────────────────────────────────────────────────────────────
+
+func TestAPI_DBLoot_AllFieldsStored(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "loot.lua": `
+ function on_response(r)
+ db.loot_add("my finding", "Critical", "detailed notes", "raw req", "raw resp")
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ require.Len(t, loot, 1)
+ assert.Equal(t, "my finding", loot[0].Title)
+ assert.Equal(t, "Critical", loot[0].Severity)
+ assert.Equal(t, "detailed notes", loot[0].Notes)
+ assert.Equal(t, "raw req", loot[0].RawRequest)
+ assert.Equal(t, "raw resp", loot[0].RawResponse)
+}
+
+func TestAPI_DBLoot_AddGetDelete(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "loot.lua": `
+ function on_response(r)
+ local id = db.loot_add("finding", "High", "notes", "req", "resp")
+ local entries = db.loot_get()
+ db.loot_add("count:" .. tostring(#entries), "Info")
+ db.loot_delete(id)
+ local after = db.loot_get()
+ db.loot_add("after:" .. tostring(#after), "Info")
+ end
+ `,
+ })
+ e.ObserveResponse(responseEvent())
+ time.Sleep(50 * time.Millisecond)
+
+ loot, err := st.AllLoot()
+ require.NoError(t, err)
+ titles := map[string]bool{}
+ for _, l := range loot {
+ titles[l.Title] = true
+ }
+ assert.True(t, titles["count:1"])
+ assert.True(t, titles["after:1"])
+}
+
+// ── Concurrency ───────────────────────────────────────────────────────────────
+
+func TestEngine_ConcurrentHandleRequest_Safe(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{
+ "concurrent.lua": `function on_request(r) r.body = r.body .. "-ok"; return r end`,
+ })
+ const goroutines = 30
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func() {
+ defer wg.Done()
+ result := e.HandleRequest(requestEvent(t, "GET", "http://example.com", []byte("body")))
+ assert.False(t, result.Drop)
+ }()
+ }
+ wg.Wait()
+}
+
+func TestEngine_ConcurrentObserveResponse_Safe(t *testing.T) {
+ st := newTestStore(t)
+ e := newEngine(t, st, map[string]string{"obs.lua": `function on_response(r) end`})
+ const goroutines = 30
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func() {
+ defer wg.Done()
+ e.ObserveResponse(responseEvent())
+ }()
+ }
+ wg.Wait()
+}