From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/plugin/engine_test.go | 1003 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 internal/plugin/engine_test.go (limited to 'internal/plugin/engine_test.go') 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() +} -- cgit v1.2.3