Files
new-api/router/plugin_router_test.go

885 lines
36 KiB
Go

package router
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestPluginDispatcherMissFallsThroughWithoutLeakingInnerResponse(t *testing.T) {
outer, registry := newPluginRouterTest(t, nil, nil)
dispatcher := (&pluginRouteDispatcher{registry: registry}).dispatch
outer.NoRoute(
dispatcher,
func(c *gin.Context) {
assert.Equal(t, "before", c.GetString(middleware.RouteTagKey))
c.Header("X-Fallback", "true")
c.String(http.StatusOK, "spa")
},
)
recorder := performPluginRequest(outer, http.MethodGet, "/not-owned")
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "spa", recorder.Body.String())
assert.Equal(t, "true", recorder.Header().Get("X-Fallback"))
assert.Empty(t, recorder.Header().Get("Location"))
}
func TestPluginDebugLogsOnlyOwnedRoutes(t *testing.T) {
previousDebug := common.DebugEnabled
common.DebugEnabled = true
t.Cleanup(func() { common.DebugEnabled = previousDebug })
var output bytes.Buffer
common.LogWriterMu.Lock()
previousWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &output
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousWriter
common.LogWriterMu.Unlock()
})
plugin := compileRouterPlugin(t, "debug-owner", "1.0.0", `[
{method: "GET", path: "/vendor/debug", type: "dynamic"}
]`)
handlers := func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
production := productionPluginRouteHandlers(generation, binding)
return []gin.HandlerFunc{
production[0],
func(c *gin.Context) { c.Status(http.StatusNoContent) },
}
}
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
output.Reset()
owned := performPluginRequest(outer, http.MethodGet, "/vendor/debug")
require.Equal(t, http.StatusNoContent, owned.Code)
logOutput := output.String()
assert.Contains(t, logOutput, "request-phase-two")
assert.Contains(t, logOutput, "task_plugin subsystem=router event=route_matched")
assert.Contains(t, logOutput, `plugin="debug-owner"`)
assert.NotContains(t, logOutput, "/vendor/debug")
assert.Contains(t, logOutput, "event=route_complete")
output.Reset()
miss := performPluginRequest(outer, http.MethodGet, "/not-owned")
require.Equal(t, http.StatusOK, miss.Code)
assert.Equal(t, "fallback", miss.Body.String())
assert.NotContains(t, output.String(), "task_plugin")
}
func TestPluginAuthoredHeaderOnly404PassesThrough(t *testing.T) {
plugin := compileRouterPlugin(t, "authored-404", "1.0.0", `[
{method: "GET", path: "/vendor/missing", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.Header("X-Plugin", "authored")
c.Status(http.StatusNotFound)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/missing")
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.Empty(t, recorder.Body.String())
assert.Equal(t, "authored", recorder.Header().Get("X-Plugin"))
}
func TestPluginOwnedPathMethodMismatchReturns405(t *testing.T) {
plugin := compileRouterPlugin(t, "method-owner", "1.0.0", `[
{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodPost, "/vendor/jobs/task-1")
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Empty(t, recorder.Body.String())
}
func TestPluginTrailingSlashMissDoesNotRedirect(t *testing.T) {
for _, testCase := range []struct {
name string
routePath string
requestPath string
}{
{name: "declared without slash", routePath: "/vendor/job", requestPath: "/vendor/job/"},
{name: "declared with slash", routePath: "/vendor/job/", requestPath: "/vendor/job"},
} {
t.Run(testCase.name, func(t *testing.T) {
plugin := compileRouterPlugin(t, "slash-owner", "1.0.0", fmt.Sprintf(`[
{method: "GET", path: %q, type: "dynamic"}
]`, testCase.routePath))
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodGet, testCase.requestPath)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "fallback", recorder.Body.String())
assert.Empty(t, recorder.Header().Get("Location"))
})
}
}
func TestPluginSSEFlushesWithoutFallbackBuffering(t *testing.T) {
plugin := compileRouterPlugin(t, "stream-owner", "1.0.0", `[
{method: "GET", path: "/vendor/stream", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString("data: ready\n\n")
c.Writer.Flush()
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
middleware.RouteTag("web"),
gzip.Gzip(gzip.DefaultCompression),
middleware.Cache(),
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
request := httptest.NewRequest(http.MethodGet, "/vendor/stream", nil)
request.Header.Set("Accept-Encoding", "gzip")
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
assert.True(t, recorder.Flushed)
assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
assert.Equal(t, "data: ready\n\n", recorder.Body.String())
assert.Empty(t, recorder.Header().Get("Content-Encoding"))
assert.Empty(t, recorder.Header().Get("Cache-Control"))
assert.Empty(t, recorder.Header().Get("Cache-Version"))
}
func TestWebCacheHeadersDoNotLeakOntoPluginRoutes(t *testing.T) {
plugin := compileRouterPlugin(t, "cache-owner", "1.0.0", `[
{method: "GET", path: "/vendor/status/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.JSON(http.StatusOK, gin.H{"status": "queued"})
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
middleware.Cache(),
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
pluginResponse := performPluginRequest(outer, http.MethodGet, "/vendor/status/task-1")
assert.Empty(t, pluginResponse.Header().Get("Cache-Control"))
assert.Empty(t, pluginResponse.Header().Get("Cache-Version"))
fallbackResponse := performPluginRequest(outer, http.MethodGet, "/unknown")
assert.Equal(t, "max-age=604800", fallbackResponse.Header().Get("Cache-Control"))
assert.NotEmpty(t, fallbackResponse.Header().Get("Cache-Version"))
}
func TestPluginInnerContextImportsRequestMetadataAndTrustedProxyConfig(t *testing.T) {
plugin := compileRouterPlugin(t, "context-owner", "1.0.0", `[
{method: "GET", path: "/vendor/context", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.JSON(http.StatusOK, gin.H{
"request_id": c.GetString(common.RequestIdKey),
"language": c.GetString(string(constant.ContextKeyLanguage)),
"client_ip": c.ClientIP(),
"route_tag": c.GetString(middleware.RouteTagKey),
})
})
outer, registry := newPluginRouterTestWithProxies(t, []*jsplugin.LoadedPlugin{plugin}, handlers, []string{"127.0.0.0/8"})
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
request := httptest.NewRequest(http.MethodGet, "/vendor/context", nil)
request.RemoteAddr = "127.0.0.1:1234"
request.Header.Set("X-Forwarded-For", "203.0.113.20")
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
assert.JSONEq(t, `{"request_id":"request-phase-two","language":"zh-CN","client_ip":"203.0.113.20","route_tag":"relay"}`, recorder.Body.String())
}
func TestPluginRequestPinsGenerationAcrossHotSwap(t *testing.T) {
v1 := compileRouterPlugin(t, "hot-swap", "1.0.0", `[
{method: "GET", path: "/vendor/version", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "hot-swap", "2.0.0", `[
{method: "GET", path: "/vendor/version", type: "dynamic"}
]`)
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
var registry *jsplugin.Registry
handlers := testPluginRouteHandlers(func(c *gin.Context, generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
if binding.Plugin.Meta.Version == "1.0.0" {
startOnce.Do(func() { close(started) })
<-release
}
pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute)
pinned, ok := pinnedValue.(jsplugin.PinnedRoute)
if !exists || !ok || pinned.Plugin == nil || pinned.Generation == nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"version": binding.Plugin.Meta.Version,
"generation": generation.Number,
"pinned_version": pinned.Plugin.Meta.Version,
"pinned_generation": pinned.Generation.Number,
"current_generation": registry.Generation().Number,
})
})
outer, activeRegistry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{v1}, handlers)
registry = activeRegistry
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
firstGeneration := registry.Generation().Number
firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
firstDone <- performPluginRequest(outer, http.MethodGet, "/vendor/version")
}()
select {
case <-started:
case <-time.After(2 * time.Second):
require.FailNow(t, "first generation request did not start")
}
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
secondGeneration := registry.Generation().Number
close(release)
var first *httptest.ResponseRecorder
select {
case first = <-firstDone:
case <-time.After(2 * time.Second):
require.FailNow(t, "pinned first generation request did not finish")
}
assert.JSONEq(t, fmt.Sprintf(`{
"version": "1.0.0",
"generation": %d,
"pinned_version": "1.0.0",
"pinned_generation": %d,
"current_generation": %d
}`, firstGeneration, firstGeneration, secondGeneration), first.Body.String())
second := performPluginRequest(outer, http.MethodGet, "/vendor/version")
assert.JSONEq(t, fmt.Sprintf(`{
"version": "2.0.0",
"generation": %d,
"pinned_version": "2.0.0",
"pinned_generation": %d,
"current_generation": %d
}`, secondGeneration, secondGeneration, secondGeneration), second.Body.String())
}
func TestPluginStaticRouteConflictsAreExcluded(t *testing.T) {
tests := []struct {
name string
staticPath string
pluginPath string
}{
{name: "parameter intersection", staticPath: "/core/:id", pluginPath: "/core/fixed"},
{name: "trailing slash redirect shadow", staticPath: "/fixed", pluginPath: "/fixed/"},
{name: "catchall redirect shadow", staticPath: "/files/*filepath", pluginPath: "/files"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET(testCase.staticPath, func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
plugin := compileRouterPlugin(t, "static-conflict", "1.0.0", fmt.Sprintf(`[
{method: "POST", path: %q, type: "submit"}
]`, testCase.pluginPath))
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{plugin}))
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodPost, testCase.pluginPath)
assert.Equal(t, "fallback", recorder.Body.String())
assert.Contains(t, registry.RoutingErrors()["static-conflict"], "intersects static route")
})
}
}
func TestStaticConflictingUpdateRetainsIncumbentAndPublishesHealthyPeer(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
incumbent := compileRouterPlugin(t, "static-update", "1.0.0", `[
{method: "GET", path: "/safe/incumbent", type: "dynamic"}
]`)
conflictingUpdate := compileRouterPlugin(t, "static-update", "2.0.0", `[
{method: "POST", path: "/core/fixed", type: "submit"}
]`)
healthyV1 := compileRouterPlugin(t, "healthy-peer", "1.0.0", `[
{method: "GET", path: "/safe/healthy", type: "dynamic"}
]`)
healthyV2 := compileRouterPlugin(t, "healthy-peer", "2.0.0", `[
{method: "GET", path: "/safe/healthy", type: "dynamic"}
]`)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{incumbent, healthyV1}))
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingUpdate, healthyV2}))
activeIncumbent, ok := registry.Get("static-update")
require.True(t, ok)
assert.Same(t, incumbent, activeIncumbent)
activeHealthy, ok := registry.Get("healthy-peer")
require.True(t, ok)
assert.Same(t, healthyV2, activeHealthy)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/safe/incumbent").Body.String())
assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/safe/healthy").Body.String())
assert.Contains(t, registry.RoutingErrors()["static-update"], "intersects static route")
}
func TestStaticConflictingNewOverrideRetainsFactoryRoute(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
factory, err := registry.RegisterFactory(routerPluginSource("factory-route", "1.0.0", `[
{method: "GET", path: "/factory-route/safe", type: "dynamic"}
]`), jsplugin.Options{})
require.NoError(t, err)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
conflictingOverride := compileRouterPlugin(t, "factory-route", "2.0.0", `[
{method: "POST", path: "/core/fixed", type: "submit"}
]`)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingOverride}))
active, ok := registry.Get("factory-route")
require.True(t, ok)
assert.Same(t, factory, active)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/factory-route/safe").Body.String())
assert.Contains(t, registry.RoutingErrors()["factory-route"], "intersects static route")
}
func TestPluginRouteOwnershipCanSwapWithinOneGeneration(t *testing.T) {
alphaV1 := compileRouterPlugin(t, "route-swap-alpha", "1.0.0", `[
{method: "GET", path: "/route-swap/alpha", type: "dynamic"}
]`)
betaV1 := compileRouterPlugin(t, "route-swap-beta", "1.0.0", `[
{method: "GET", path: "/route-swap/beta", type: "dynamic"}
]`)
alphaV2 := compileRouterPlugin(t, "route-swap-alpha", "2.0.0", `[
{method: "GET", path: "/route-swap/beta", type: "dynamic"}
]`)
betaV2 := compileRouterPlugin(t, "route-swap-beta", "2.0.0", `[
{method: "GET", path: "/route-swap/alpha", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1}, handlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2}))
assert.Equal(t, "route-swap-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/alpha").Body.String())
assert.Equal(t, "route-swap-alpha@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/beta").Body.String())
assert.Empty(t, registry.RoutingErrors())
}
func TestRejectedRouteUpdateDoesNotFreezeHealthyPeer(t *testing.T) {
alphaV1 := compileRouterPlugin(t, "route-fallback-alpha", "1.0.0", `[
{method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
]`)
betaV1 := compileRouterPlugin(t, "route-fallback-beta", "1.0.0", `[
{method: "GET", path: "/route-fallback/beta", type: "dynamic"}
]`)
owner := compileRouterPlugin(t, "route-fallback-owner", "1.0.0", `[
{method: "GET", path: "/route-fallback/owner", type: "dynamic"}
]`)
alphaV2 := compileRouterPlugin(t, "route-fallback-alpha", "2.0.0", `[
{method: "POST", path: "/route-fallback/owner", type: "submit"}
]`)
betaV2 := compileRouterPlugin(t, "route-fallback-beta", "2.0.0", `[
{method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1, owner}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2, owner}))
_, alphaActive := registry.Get("route-fallback-alpha")
assert.False(t, alphaActive)
activeBeta, ok := registry.Get("route-fallback-beta")
require.True(t, ok)
assert.Same(t, betaV2, activeBeta)
assert.Equal(t, "route-fallback-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-fallback/alpha").Body.String())
assert.Contains(t, registry.RoutingErrors()["route-fallback-alpha"], "overlaps plugin route-fallback-owner")
assert.NotContains(t, registry.RoutingErrors(), "route-fallback-beta")
}
func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) {
alpha := compileRouterPlugin(t, "alpha-owner", "1.0.0", `[
{method: "GET", path: "/shared/jobs/:task_id", type: "query", render: "native"}
]`)
beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[
{method: "POST", path: "/shared/jobs/:task_id", type: "submit"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{beta, alpha}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
getResponse := performPluginRequest(outer, http.MethodGet, "/shared/jobs/task-1")
assert.Equal(t, "alpha-owner", getResponse.Body.String())
postResponse := performPluginRequest(outer, http.MethodPost, "/shared/jobs/task-1")
assert.Equal(t, http.StatusMethodNotAllowed, postResponse.Code)
assert.Contains(t, registry.RoutingErrors()["beta-owner"], "overlaps plugin alpha-owner")
}
func TestOnePluginMayOwnMultipleMethodsForSamePath(t *testing.T) {
plugin := compileRouterPlugin(t, "multi-method", "1.0.0", `[
{method: "GET", path: "/vendor/multi/:task_id", type: "query", render: "native"},
{method: "POST", path: "/vendor/multi/:task_id", type: "submit"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, c.Request.Method)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
assert.Equal(t, http.MethodGet, performPluginRequest(outer, http.MethodGet, "/vendor/multi/task-1").Body.String())
assert.Equal(t, http.MethodPost, performPluginRequest(outer, http.MethodPost, "/vendor/multi/task-1").Body.String())
assert.NotContains(t, registry.RoutingErrors(), "multi-method")
}
func TestGinWildcardNameConflictRejectsWholePlugin(t *testing.T) {
plugin := compileRouterPlugin(t, "wildcard-conflict", "1.0.0", `[
{method: "GET", path: "/vendor/:id/first", type: "query", render: "native", taskIdParam: "id"},
{method: "GET", path: "/vendor/:name/second", type: "query", render: "native", taskIdParam: "name"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/vendor/1/first").Body.String())
assert.Contains(t, registry.RoutingErrors()["wildcard-conflict"], "incompatible wildcard names")
}
func TestGinRegistrationPanicRebuildsWithoutOffender(t *testing.T) {
alpha := compileRouterPlugin(t, "panic-alpha", "1.0.0", `[
{method: "GET", path: "/panic/alpha", type: "dynamic"}
]`)
beta := compileRouterPlugin(t, "panic-beta", "1.0.0", `[
{method: "GET", path: "/panic/beta", type: "dynamic"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
outer := newOuterPluginTestEngine()
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
},
))
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Key == "panic-beta" {
panic("registration failed")
}
normalRegister(engine, binding, handlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
assert.Equal(t, "panic-alpha", performPluginRequest(outer, http.MethodGet, "/panic/alpha").Body.String())
assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/panic/beta").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-beta"], "registration panic")
}
func TestGinRegistrationPanicReadmitsPluginBlockedByOffender(t *testing.T) {
alpha := compileRouterPlugin(t, "panic-owner-alpha", "1.0.0", `[
{method: "GET", path: "/panic/reconsider", type: "dynamic"}
]`)
beta := compileRouterPlugin(t, "panic-owner-beta", "1.0.0", `[
{method: "POST", path: "/panic/reconsider", type: "submit"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
outer := newOuterPluginTestEngine()
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
},
))
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Key == "panic-owner-alpha" {
panic("registration failed")
}
normalRegister(engine, binding, handlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
_, alphaActive := registry.Get("panic-owner-alpha")
assert.False(t, alphaActive)
activeBeta, ok := registry.Get("panic-owner-beta")
require.True(t, ok)
assert.Same(t, beta, activeBeta)
assert.Equal(t, "panic-owner-beta", performPluginRequest(outer, http.MethodPost, "/panic/reconsider").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-owner-alpha"], "registration panic")
assert.NotContains(t, registry.RoutingErrors(), "panic-owner-beta")
}
func TestUpdatedRegistrationPanicRestoresIncumbent(t *testing.T) {
v1 := compileRouterPlugin(t, "panic-update", "1.0.0", `[
{method: "GET", path: "/panic/stable", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "panic-update", "2.0.0", `[
{method: "GET", path: "/panic/stable", type: "dynamic"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
outer := newOuterPluginTestEngine()
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Version == "2.0.0" {
panic("new version registration failed")
}
normalRegister(engine, binding, routeHandlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
active, ok := registry.Get("panic-update")
require.True(t, ok)
assert.Same(t, v1, active)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/panic/stable").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-update"], "registration panic")
}
func TestUnattributableRebuildFailureRetainsOldGeneration(t *testing.T) {
v1 := compileRouterPlugin(t, "rebuild-stable", "1.0.0", `[
{method: "GET", path: "/rebuild/version", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "rebuild-stable", "2.0.0", `[
{method: "GET", path: "/rebuild/version", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
outer := newOuterPluginTestEngine()
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
before := registry.Generation()
normalConfigure := builder.configure
builder.configure = func(*gin.Engine) error { return errors.New("engine configuration failed") }
require.Error(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
assert.Same(t, before, registry.Generation())
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
assert.Contains(t, registry.LastRebuildError(), "engine configuration failed")
builder.configure = normalConfigure
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
}
func TestPluginRouteRecoverySanitizesPanicResponse(t *testing.T) {
plugin := compileRouterPlugin(t, "panic-route", "1.0.0", `[
{method: "GET", path: "/vendor/panic", type: "dynamic"}
]`)
var calls atomic.Int32
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
if calls.Add(1) == 1 {
panic("https://secret.example/internal?token=credential")
}
c.String(http.StatusOK, "recovered")
},
))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
assert.Contains(t, recorder.Body.String(), "internal plugin route error")
assert.NotContains(t, recorder.Body.String(), "secret.example")
assert.NotContains(t, recorder.Body.String(), "credential")
second := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
assert.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, "recovered", second.Body.String())
}
func TestProductionPluginRoutePipelineRequiresTokenAuth(t *testing.T) {
plugin := compileRouterPlugin(t, "forced-auth", "1.0.0", `[
{method: "GET", path: "/vendor/protected/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, productionPluginRouteHandlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/protected/task-1")
assert.NotEqual(t, http.StatusNotImplemented, recorder.Code)
assert.Contains(t, recorder.Body.String(), "error")
}
func TestProductionPluginNativeQueryTraversesInnerRouter(t *testing.T) {
previousDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.Task{}))
model.DB = database
t.Cleanup(func() { model.DB = previousDB })
require.NoError(t, database.Create(&model.Task{
TaskID: "task_native_router",
Platform: constant.TaskPlatform("kling"),
UserId: 91,
Status: model.TaskStatusSuccess,
Progress: "100%",
ChannelId: 17,
PrivateData: model.TaskPrivateData{
UpstreamTaskID: "private_upstream_id",
ResultURL: "https://secret.example/video.mp4",
},
}).Error)
kling, found := jsplugin.DefaultRegistry.Get("kling")
require.True(t, found)
authenticatedProductionHandlers := func(
generation *jsplugin.RoutingGeneration,
binding jsplugin.RouteBinding,
) []gin.HandlerFunc {
production := productionPluginRouteHandlers(generation, binding)
return []gin.HandlerFunc{
production[0],
func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUserId, 91)
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
common.SetContextKey(c, constant.ContextKeyTokenGroup, "default")
c.Next()
},
production[2],
production[3],
production[4],
production[5],
production[6],
}
}
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{kling}, authenticatedProductionHandlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
request := httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_native_router", nil)
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.Contains(t, recorder.Body.String(), `"task_id":"task_native_router"`)
assert.Contains(t, recorder.Body.String(), `"task_status":"succeed"`)
assert.NotContains(t, recorder.Body.String(), "private_upstream_id")
assert.NotContains(t, recorder.Body.String(), "secret.example")
}
func newPluginRouterTest(
t *testing.T,
plugins []*jsplugin.LoadedPlugin,
handlers pluginRouteHandlers,
) (*gin.Engine, *jsplugin.Registry) {
t.Helper()
return newPluginRouterTestWithProxies(t, plugins, handlers, nil)
}
func newPluginRouterTestWithProxies(
t *testing.T,
plugins []*jsplugin.LoadedPlugin,
handlers pluginRouteHandlers,
trustedProxies []string,
) (*gin.Engine, *jsplugin.Registry) {
t.Helper()
gin.SetMode(gin.TestMode)
outer := newOuterPluginTestEngine()
registry := jsplugin.NewRegistry()
if plugins != nil {
require.NoError(t, registry.ReplaceOverrides(plugins))
}
if handlers == nil {
handlers = testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
})
}
builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
return outer, registry
}
func newOuterPluginTestEngine() *gin.Engine {
outer := gin.New()
outer.Use(func(c *gin.Context) {
c.Set(common.RequestIdKey, "request-phase-two")
c.Set(string(constant.ContextKeyLanguage), "zh-CN")
c.Set(middleware.RouteTagKey, "before")
c.Next()
})
return outer
}
func testPluginRouteHandlers(
handler func(*gin.Context, *jsplugin.RoutingGeneration, jsplugin.RouteBinding),
) pluginRouteHandlers {
return func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
return []gin.HandlerFunc{func(c *gin.Context) {
pinnedGeneration := generation
if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
pinnedGeneration = state.generation
}
c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
Route: binding.Route,
})
handler(c, pinnedGeneration, binding)
}}
}
}
func compileRouterPlugin(t *testing.T, key, version, routes string) *jsplugin.LoadedPlugin {
t.Helper()
plugin, err := jsplugin.CompilePlugin(routerPluginSource(key, version, routes), jsplugin.Options{Key: key, Version: version})
require.NoError(t, err)
return plugin
}
func routerPluginSource(key, version, routes string) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: %q,
author: {name: "Test"},
models: ["model"],
fetchMode: "per_task",
routes: (%s).map(function(route) {
const migrated = Object.assign({}, route);
delete migrated.renderer;
migrated.render = route.render || route.renderer || "render";
if (route.type !== "query") migrated.decode = route.decode || "decode";
return migrated;
}),
};
export const native = {
decode: function(ctx) { return {kind: "submit", model: "model", requestBody: ctx.body.value}; },
render: function(ctx, task) { return task; },
native: function(ctx, task) { return task; },
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, key, version, routes)
}
func performPluginRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, strings.NewReader(""))
handler.ServeHTTP(recorder, request)
return recorder
}