package gateway // The /api/plugins/{name}/call/{fn} endpoint splits its path with string // surgery, and the split point is easy to get wrong in a way that still // "works": keeping the "call/" segment in the function name produced // `does not export "call/collect_account"` — the plugin was correct, the router // was not, and every call 502'd while the exports list looked perfectly fine. // // These tests drive the real router, so a refactor that reintroduces the // off-by-one is caught here rather than in production. import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "testing" "llmsproxy/internal/config" "llmsproxy/internal/core" ) // callPlugin exposes exactly one export plus a function that is NOT exported. // If the exports gate is not honoured, secretFn is reachable and with it the // plugin's private state — for a plugin holding an API key that means handing // the key to anyone who can POST to the gateway. const callPlugin = ` local P = {} P.name = "calltest" P.version = "1.0.0" P.exports = { "add" } P.secret = "do-not-leak" function P.add(a) return { result = (tonumber(a and a.x) or 0) + 1 } end function P.secretFn() return { leaked = P.secret } end return P ` func gatewayWithCallPlugin(t *testing.T) *Gateway { t.Helper() dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") body := "listen: :0\n" + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + "gateway_keys:\n - sk-test\n" if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil { t.Fatal(err) } cfg, err := config.Load(cfgPath) if err != nil { t.Fatal(err) } c, err := core.NewFromConfig(cfg) if err != nil { t.Fatalf("core: %v", err) } t.Cleanup(c.Close) if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil { t.Fatalf("load calltest: %v", err) } g, err := New(c) if err != nil { t.Fatalf("gateway: %v", err) } return g } func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) { g := gatewayWithCallPlugin(t) rr := doReq(t, g, http.MethodPost, "/api/plugins/calltest/call/add", `{"x":41}`) if rr.Code != http.StatusOK { t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String()) } var out struct { Result struct { Result int `json:"result"` } `json:"result"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v (body=%s)", err, rr.Body.String()) } // 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/" // segment did not leak into the function name, and the payload arrived. if out.Result.Result != 42 { t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+ "function name or the argument table never reached Lua", out.Result.Result) } } func TestPluginCallRefusesUndeclaredFunction(t *testing.T) { g := gatewayWithCallPlugin(t) rr := doReq(t, g, http.MethodPost, "/api/plugins/calltest/call/secretFn", `{}`) if rr.Code == http.StatusOK { t.Fatalf("an unexported function must not be callable, got 200: %s", rr.Body.String()) } if strings.Contains(rr.Body.String(), "do-not-leak") { t.Fatal("the refusal leaked the plugin's private state") } } // A plugin that declares no exports at all has no callable surface. Reading the // missing list as "allow everything" would turn every plugin into a remote // evaluator the moment this endpoint ships. func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) { g := gatewayWithCallPlugin(t) const bare = ` local P = {} P.name = "bare" P.version = "1.0.0" function P.anything() return { ok = true } end return P ` if err := g.core.Plugins().LoadSource("bare", bare); err != nil { t.Fatalf("load bare: %v", err) } rr := doReq(t, g, http.MethodPost, "/api/plugins/bare/call/anything", `{}`) if rr.Code == http.StatusOK { t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s", rr.Body.String()) } } func TestPluginCallIsAdminOnly(t *testing.T) { g := gatewayWithCallPlugin(t) // A real user-role key, not a bare one from config: sk-test/sk-user in the // config both default to admin, so a test using them would pass the gate for // the wrong reason and prove nothing. rec, err := g.core.CreateKey("viewer", "user", nil, "") if err != nil { t.Fatal(err) } rr := doReqAs(t, g, rec.Key, http.MethodPost, "/api/plugins/calltest/call/add", `{"x":1}`) if rr.Code != http.StatusForbidden { t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s", rr.Code, rr.Body.String()) } }