feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)

This commit is contained in:
Calcium-Ion
2026-08-29 18:51:57 +08:00
committed by GitHub
parent 7037ac15bd
commit eb48396d5f
336 changed files with 52333 additions and 6369 deletions
+20 -1
View File
@@ -3,6 +3,7 @@ package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/service/authz"
// Import oauth package to register providers via init()
_ "github.com/QuantumNous/new-api/oauth"
@@ -231,6 +232,23 @@ func SetApiRouter(router *gin.Engine) {
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
taskPluginRoute := apiRouter.Group("/plugin/task")
taskPluginRoute.Use(middleware.RootAuth())
{
taskPluginRoute.GET("", controller.ListTaskPlugins)
taskPluginRoute.POST("", controller.UploadTaskPlugin)
taskPluginRoute.PUT("", controller.UploadTaskPlugin)
taskPluginRoute.GET("/runtime/status", controller.GetTaskPluginRuntime)
taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources)
taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources)
taskPluginRoute.GET("/:key", controller.GetTaskPlugin)
taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions)
taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin)
taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus)
taskPluginRoute.POST("/:key/dryrun", controller.DryRunTaskPlugin)
taskPluginRoute.DELETE("/:key/versions/:version", controller.DeleteTaskPluginVersion)
}
apiRouter.GET("/task_plugin_options", middleware.AdminAuth(), middleware.RequirePermission(authz.TaskPluginBind), controller.GetTaskPluginOptions)
registerChannelRoutes(apiRouter)
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
@@ -327,7 +345,8 @@ func SetApiRouter(router *gin.Engine) {
taskRoute := apiRouter.Group("/task")
{
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
taskRoute.GET("", middleware.AdminAuth(), controller.GetAllTask)
taskRoute.GET("/:task_id/artifacts", middleware.UserAuth(), controller.GetDashboardTaskArtifacts)
}
vendorRoute := apiRouter.Group("/vendors")
+11 -5
View File
@@ -16,19 +16,25 @@ func SetRouter(router *gin.Engine, assets WebAssets) {
SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router)
SetTaskPluginProtocolRouter(router)
SetVideoRouter(router)
SetTaskRouter(router)
pluginDispatcher := SetPluginRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
}
if frontendBaseUrl == "" {
SetWebRouter(router, assets)
SetWebRouter(router, assets, pluginDispatcher)
} else {
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web")
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
})
router.NoRoute(
pluginDispatcher,
middleware.RouteTag("web"),
func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
},
)
}
}
+588
View File
@@ -0,0 +1,588 @@
package router
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
)
type pluginDispatchStateKey struct{}
type pluginDispatchState struct {
generation *jsplugin.RoutingGeneration
hit atomic.Bool
writer *gatedResponseWriter
requestID string
language string
}
func (s *pluginDispatchState) markHit() {
s.hit.Store(true)
s.writer.activate()
}
type pluginRouteHandlers func(*jsplugin.RoutingGeneration, jsplugin.RouteBinding) []gin.HandlerFunc
type pluginGenerationBuilder struct {
staticRoutes []gin.RouteInfo
trustedProxies []string
routeHandlers pluginRouteHandlers
registerRoute func(*gin.Engine, jsplugin.RouteBinding, []gin.HandlerFunc)
configure func(*gin.Engine) error
}
type pluginRouteDispatcher struct {
registry *jsplugin.Registry
}
func SetPluginRouter(outer *gin.Engine) gin.HandlerFunc {
trustedProxies, _, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
dispatcher := &pluginRouteDispatcher{registry: jsplugin.DefaultRegistry}
if err != nil {
common.SysError("configure plugin router trusted proxies: " + err.Error())
return dispatcher.dispatch
}
builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, productionPluginRouteHandlers)
if err = jsplugin.DefaultRegistry.SetGenerationPreparer(builder.prepare); err != nil {
common.SysError("build initial plugin router: " + err.Error())
}
return dispatcher.dispatch
}
func newPluginGenerationBuilder(staticRoutes []gin.RouteInfo, trustedProxies []string, handlers pluginRouteHandlers) *pluginGenerationBuilder {
builder := &pluginGenerationBuilder{
staticRoutes: append([]gin.RouteInfo(nil), staticRoutes...),
trustedProxies: append([]string(nil), trustedProxies...),
routeHandlers: handlers,
}
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
engine.Handle(binding.Route.Method, binding.Route.Path, routeHandlers...)
}
builder.configure = func(engine *gin.Engine) error {
return common.ConfigureTrustedProxies(engine, builder.trustedProxies)
}
return builder
}
func productionPluginRouteHandlers(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
pinRoute := 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.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
})
c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
Route: binding.Route,
})
logger.LogDebug(
c,
"task_plugin subsystem=router event=route_matched generation=%d plugin=%q version=%q method=%q route_type=%q",
pinnedGeneration.Number,
binding.Plugin.Meta.Key,
binding.Plugin.Meta.Version,
binding.Route.Method,
binding.Route.Type,
)
c.Next()
logger.LogDebug(
c,
"task_plugin subsystem=router event=route_complete generation=%d plugin=%q method=%q status=%d",
pinnedGeneration.Number,
binding.Plugin.Meta.Key,
binding.Route.Method,
c.Writer.Status(),
)
}
return []gin.HandlerFunc{
pinRoute,
middleware.TokenAuth(),
middleware.SystemPerformanceCheck(),
middleware.ModelRequestRateLimit(),
middleware.PrepareTaskPluginRoute(),
middleware.Distribute(),
controller.RelayTask,
}
}
func (b *pluginGenerationBuilder) prepare(candidate, current *jsplugin.RoutingGeneration) (jsplugin.PreparedRoutingGeneration, error) {
blocked := make(map[*jsplugin.LoadedPlugin]string)
for {
accepted, routingErrors := b.admitPlugins(candidate, current, blocked)
plugins := sortedPlugins(accepted)
filtered, err := candidate.RebuildWithPlugins(plugins)
if err != nil {
return jsplugin.PreparedRoutingGeneration{}, err
}
engine, offender, buildErr := b.buildInnerEngine(filtered)
if buildErr == nil {
return jsplugin.PreparedRoutingGeneration{
Generation: filtered.WithRuntime(engine),
Errors: routingErrors,
}, nil
}
if offender == "" {
return jsplugin.PreparedRoutingGeneration{}, buildErr
}
failedPlugin := accepted[offender]
if failedPlugin == nil {
return jsplugin.PreparedRoutingGeneration{}, fmt.Errorf("public route rebuild attributed failure to absent plugin %q: %w", offender, buildErr)
}
blocked[failedPlugin] = fmt.Sprintf("plugin %s rejected while rebuilding public routes: %v", offender, buildErr)
}
}
func (b *pluginGenerationBuilder) admitPlugins(
candidate, current *jsplugin.RoutingGeneration,
blocked map[*jsplugin.LoadedPlugin]string,
) (map[string]*jsplugin.LoadedPlugin, map[string]string) {
accepted := make(map[string]*jsplugin.LoadedPlugin)
currentByKey := make(map[string]*jsplugin.LoadedPlugin)
if current != nil && current.RuntimeHandler() != nil {
for _, plugin := range current.Plugins() {
currentByKey[plugin.Meta.Key] = plugin
}
}
unchangedKeys := make([]string, 0)
changedKeys := make([]string, 0)
newKeys := make([]string, 0)
for _, plugin := range candidate.Plugins() {
incumbent, existed := currentByKey[plugin.Meta.Key]
switch {
case existed && incumbent == plugin:
unchangedKeys = append(unchangedKeys, plugin.Meta.Key)
case existed:
changedKeys = append(changedKeys, plugin.Meta.Key)
default:
newKeys = append(newKeys, plugin.Meta.Key)
}
}
sort.Strings(unchangedKeys)
sort.Strings(changedKeys)
sort.Strings(newKeys)
routingErrors := make(map[string]string)
orderedKeys := append(unchangedKeys, changedKeys...)
orderedKeys = append(orderedKeys, newKeys...)
rejectedKeys := make([]string, 0)
for _, key := range orderedKeys {
plugin, _ := candidate.Get(key)
if blockedError := blocked[plugin]; blockedError != "" {
routingErrors[key] = blockedError
} else if err := b.validatePlugin(plugin, accepted); err != nil {
routingErrors[key] = fmt.Sprintf("plugin %s rejected from public routes: %v", key, err)
} else {
accepted[key] = plugin
continue
}
rejectedKeys = append(rejectedKeys, key)
}
for _, key := range rejectedKeys {
plugin, _ := candidate.Get(key)
incumbent := currentByKey[key]
if incumbent == nil || incumbent == plugin || !candidate.RetainsIncumbent(key) {
continue
}
if blockedError := blocked[incumbent]; blockedError != "" {
routingErrors[key] = blockedError
continue
}
if err := b.validatePlugin(incumbent, accepted); err == nil {
accepted[key] = incumbent
}
}
return accepted, routingErrors
}
func (b *pluginGenerationBuilder) validatePlugin(plugin *jsplugin.LoadedPlugin, accepted map[string]*jsplugin.LoadedPlugin) error {
for _, route := range plugin.Meta.Routes {
for _, staticRoute := range b.staticRoutes {
if routeIntersectsStaticRoute(route.Path, staticRoute.Path) {
return fmt.Errorf("route %s %s intersects static route %s %s", route.Method, route.Path, staticRoute.Method, staticRoute.Path)
}
}
}
for index, left := range plugin.Meta.Routes {
for _, right := range plugin.Meta.Routes[index+1:] {
if left.Method != right.Method {
continue
}
if routePatternsIntersect(left.Path, right.Path) {
return fmt.Errorf("routes %s %s and %s overlap", left.Method, left.Path, right.Path)
}
if !routesGinCompatible(left.Path, right.Path) {
return fmt.Errorf("routes %s %s and %s use incompatible wildcard names", left.Method, left.Path, right.Path)
}
}
}
for _, other := range accepted {
if other.Meta.Key == plugin.Meta.Key {
continue
}
for _, route := range plugin.Meta.Routes {
for _, otherRoute := range other.Meta.Routes {
if routePatternsIntersect(route.Path, otherRoute.Path) {
return fmt.Errorf("route %s %s overlaps plugin %s route %s %s", route.Method, route.Path, other.Meta.Key, otherRoute.Method, otherRoute.Path)
}
if route.Method == otherRoute.Method && !routesGinCompatible(route.Path, otherRoute.Path) {
return fmt.Errorf("route %s %s is structurally incompatible with plugin %s route %s", route.Method, route.Path, other.Meta.Key, otherRoute.Path)
}
}
}
}
return nil
}
func (b *pluginGenerationBuilder) buildInnerEngine(generation *jsplugin.RoutingGeneration) (engine *gin.Engine, offender string, err error) {
currentPlugin := ""
defer func() {
if recovered := recover(); recovered != nil {
offender = currentPlugin
err = fmt.Errorf("inner Gin registration panic: %v", recovered)
engine = nil
}
}()
engine = gin.New()
engine.RedirectTrailingSlash = false
engine.HandleMethodNotAllowed = true
if err = b.configure(engine); err != nil {
return nil, "", err
}
engine.Use(importPluginDispatchState())
engine.Use(pluginRouteRecovery())
engine.Use(middleware.BodyStorageCleanup())
engine.NoMethod(func(c *gin.Context) {
markPluginRouteHit(c)
generation := uint64(0)
if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
generation = state.generation.Number
}
logger.LogDebug(
c,
"task_plugin subsystem=router event=method_not_allowed generation=%d request_method=%q status=%d",
generation,
c.Request.Method,
http.StatusMethodNotAllowed,
)
c.AbortWithStatus(http.StatusMethodNotAllowed)
})
for _, binding := range generation.Routes() {
currentPlugin = binding.Plugin.Meta.Key
b.registerRoute(engine, binding, b.routeHandlers(generation, binding))
}
currentPlugin = ""
return engine, "", nil
}
func importPluginDispatchState() gin.HandlerFunc {
return func(c *gin.Context) {
state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
if state != nil {
if c.FullPath() != "" {
state.markHit()
}
if state.requestID != "" {
c.Set(common.RequestIdKey, state.requestID)
}
if state.language != "" {
c.Set(string(constant.ContextKeyLanguage), state.language)
}
}
c.Set(middleware.RouteTagKey, "relay")
c.Next()
}
}
func markPluginRouteHit(c *gin.Context) {
state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
if state != nil {
state.markHit()
}
}
func pluginRouteRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recover() == nil {
return
}
common.SysError("panic recovered in plugin route")
if pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute); exists {
if pinned, ok := pinnedValue.(jsplugin.PinnedRoute); ok && pinned.Plugin != nil && pinned.Generation != nil {
logger.LogDebug(
c,
"task_plugin subsystem=router event=panic_recovered generation=%d plugin=%q method=%q",
pinned.Generation.Number,
pinned.Plugin.Meta.Key,
pinned.Route.Method,
)
}
}
c.Abort()
if !c.Writer.Written() {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "internal plugin route error",
"type": "plugin_route_error",
},
})
}
}()
c.Next()
}
}
func (d *pluginRouteDispatcher) dispatch(c *gin.Context) {
generation := d.registry.Generation()
if generation == nil || generation.RuntimeHandler() == nil {
c.Next()
return
}
previousTag, hadPreviousTag := c.Get(middleware.RouteTagKey)
c.Set(middleware.RouteTagKey, "relay")
originalContext := c.Request.Context()
state := &pluginDispatchState{
generation: generation,
requestID: c.GetString(common.RequestIdKey),
language: c.GetString(string(constant.ContextKeyLanguage)),
}
gatedWriter := newGatedResponseWriter(c.Writer)
state.writer = gatedWriter
c.Request = c.Request.WithContext(context.WithValue(originalContext, pluginDispatchStateKey{}, state))
generation.RuntimeHandler().ServeHTTP(gatedWriter, c.Request)
if state.hit.Load() {
if !c.Writer.Written() {
c.Writer.WriteHeaderNow()
}
c.Abort()
return
}
c.Request = c.Request.WithContext(originalContext)
if hadPreviousTag {
c.Set(middleware.RouteTagKey, previousTag)
} else {
delete(c.Keys, middleware.RouteTagKey)
}
c.Next()
}
type gatedResponseWriter struct {
underlying gin.ResponseWriter
privateHeader http.Header
active atomic.Bool
activateOnce sync.Once
pendingStatus int
}
func newGatedResponseWriter(underlying gin.ResponseWriter) *gatedResponseWriter {
return &gatedResponseWriter{
underlying: underlying,
privateHeader: underlying.Header().Clone(),
}
}
func (w *gatedResponseWriter) activate() {
w.activateOnce.Do(func() {
target := w.underlying.Header()
for key := range target {
target.Del(key)
}
for key, values := range w.privateHeader {
target[key] = append([]string(nil), values...)
}
w.active.Store(true)
if w.pendingStatus != 0 {
w.underlying.WriteHeader(w.pendingStatus)
}
})
}
func (w *gatedResponseWriter) Header() http.Header {
if w.active.Load() {
return w.underlying.Header()
}
return w.privateHeader
}
func (w *gatedResponseWriter) WriteHeader(statusCode int) {
if w.active.Load() {
w.underlying.WriteHeader(statusCode)
return
}
if w.pendingStatus == 0 {
w.pendingStatus = statusCode
}
}
func (w *gatedResponseWriter) Write(data []byte) (int, error) {
if !w.active.Load() {
return len(data), nil
}
return w.underlying.Write(data)
}
func (w *gatedResponseWriter) Flush() {
if w.active.Load() {
w.underlying.Flush()
}
}
func (w *gatedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if !w.active.Load() {
return nil, nil, errors.New("cannot hijack an unmatched plugin route")
}
return w.underlying.Hijack()
}
func (w *gatedResponseWriter) CloseNotify() <-chan bool {
if w.active.Load() {
return w.underlying.CloseNotify()
}
never := make(chan bool)
return never
}
func (w *gatedResponseWriter) Push(target string, options *http.PushOptions) error {
if !w.active.Load() {
return http.ErrNotSupported
}
pusher := w.underlying.Pusher()
if pusher == nil {
return http.ErrNotSupported
}
return pusher.Push(target, options)
}
func sortedPluginKeys(plugins map[string]*jsplugin.LoadedPlugin) []string {
keys := make([]string, 0, len(plugins))
for key := range plugins {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedPlugins(plugins map[string]*jsplugin.LoadedPlugin) []*jsplugin.LoadedPlugin {
keys := sortedPluginKeys(plugins)
sorted := make([]*jsplugin.LoadedPlugin, 0, len(keys))
for _, key := range keys {
sorted = append(sorted, plugins[key])
}
return sorted
}
type routePatternSegment struct {
value string
dynamic bool
catchAll bool
}
func parseRoutePattern(routePath string) []routePatternSegment {
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
segments := make([]routePatternSegment, 0, len(parts))
for _, part := range parts {
segments = append(segments, routePatternSegment{
value: part,
dynamic: strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*"),
catchAll: strings.HasPrefix(part, "*"),
})
}
return segments
}
func routePatternsIntersect(leftPath, rightPath string) bool {
left := parseRoutePattern(leftPath)
right := parseRoutePattern(rightPath)
for index := 0; ; index++ {
leftDone := index >= len(left)
rightDone := index >= len(right)
if leftDone || rightDone {
return leftDone && rightDone
}
if left[index].catchAll || right[index].catchAll {
return true
}
if !left[index].dynamic && !right[index].dynamic && left[index].value != right[index].value {
return false
}
if (left[index].dynamic && right[index].value == "") || (right[index].dynamic && left[index].value == "") {
return false
}
}
}
func routesGinCompatible(leftPath, rightPath string) bool {
left := parseRoutePattern(leftPath)
right := parseRoutePattern(rightPath)
limit := len(left)
if len(right) < limit {
limit = len(right)
}
for index := 0; index < limit; index++ {
leftSegment := left[index]
rightSegment := right[index]
if !leftSegment.dynamic && !rightSegment.dynamic {
if leftSegment.value != rightSegment.value {
return true
}
continue
}
if leftSegment.dynamic && rightSegment.dynamic {
if leftSegment.value != rightSegment.value {
return false
}
continue
}
return true
}
return true
}
func routeIntersectsStaticRoute(pluginPath, staticPath string) bool {
if routePatternsIntersect(pluginPath, staticPath) {
return true
}
if catchAllIndex := strings.LastIndex(staticPath, "/*"); catchAllIndex >= 0 && catchAllIndex+2 < len(staticPath) {
prefix := staticPath[:catchAllIndex]
if routePatternsIntersect(pluginPath, prefix) || routePatternsIntersect(pluginPath, prefix+"/") {
return true
}
return false
}
if staticPath == "/" {
return false
}
alternate := strings.TrimSuffix(staticPath, "/")
if alternate == staticPath {
alternate += "/"
}
return routePatternsIntersect(pluginPath, alternate)
}
+884
View File
@@ -0,0 +1,884 @@
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
}
-13
View File
@@ -98,9 +98,6 @@ func SetRelayRouter(router *gin.Engine) {
})
// response related routes
httpRouter.POST("/responses", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIResponses)
})
httpRouter.POST("/responses/compact", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIResponsesCompaction)
})
@@ -181,16 +178,6 @@ func SetRelayRouter(router *gin.Engine) {
registerMjRouterGroup(relayMjModeRouter)
//relayMjRouter.Use()
relaySunoRouter := router.Group("/suno")
relaySunoRouter.Use(middleware.RouteTag("relay"))
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
{
relaySunoRouter.POST("/submit/:action", controller.RelayTask)
relaySunoRouter.POST("/fetch", controller.RelayTaskFetch)
relaySunoRouter.GET("/fetch/:id", controller.RelayTaskFetch)
}
relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.RouteTag("relay"))
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
-26
View File
@@ -1,26 +0,0 @@
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestRetiredFrontendAPIRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
SetApiRouter(engine)
routes := make(map[string]struct{}, len(engine.Routes()))
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
_, hasAsyncCleanup := routes[http.MethodPost+" /api/system-task/log-cleanup"]
_, hasDirectDelete := routes[http.MethodDelete+" /api/log/"]
_, hasConsoleMigration := routes[http.MethodPost+" /api/option/migrate_console_setting"]
assert.True(t, hasAsyncCleanup)
assert.False(t, hasDirectDelete)
assert.False(t, hasConsoleMigration)
}
+52
View File
@@ -0,0 +1,52 @@
package router
import (
"fmt"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func SetTaskPluginProtocolRouter(router *gin.Engine) {
for _, protocol := range pluginruntime.HostProtocols() {
for _, operation := range protocol.Operations {
for _, method := range operation.Methods {
handlers, err := taskPluginProtocolHandlers(protocol.Name, operation.Name)
if err != nil {
panic(err)
}
router.Handle(method, operation.Path, handlers...)
}
}
}
}
func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc, error) {
switch protocol + "." + operation {
case "openai_responses.create":
return []gin.HandlerFunc{
middleware.RouteTag("relay"), middleware.SystemPerformanceCheck(), middleware.TokenAuth(),
middleware.ModelRequestRateLimit(), middleware.PinTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
func(c *gin.Context) {
controller.RelayTaskPluginEndpoint(c, func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponses) })
},
}, nil
case "openai_video.create":
return []gin.HandlerFunc{
middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.SystemPerformanceCheck(),
middleware.PinTaskPluginEndpoint(), middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
func(c *gin.Context) { controller.RelayTaskPluginEndpoint(c, controller.RelayTask) },
}, nil
case "openai_responses.retrieve":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.RetrieveTaskPluginResponse}, nil
case "openai_video.retrieve":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.Distribute(), controller.RelayTaskFetch}, nil
case "openai_video.content":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.VideoProxy}, nil
default:
return nil, fmt.Errorf("host protocol registry operation %s.%s has no handler", protocol, operation)
}
}
+37
View File
@@ -0,0 +1,37 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
// SetTaskRouter registers the generic task-plugin API surface.
//
// Gin requires every route sharing a path position to use the same wildcard
// name, so the first segment is uniformly ":key"; it carries the plugin key
// on submit routes and the task id on read routes.
func SetTaskRouter(router *gin.Engine) {
taskSubmitRouter := router.Group("/v1/tasks")
taskSubmitRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
{
taskSubmitRouter.POST("/:key", middleware.PrepareTaskPluginSubmit(), middleware.Distribute(), controller.RelayTask)
}
taskReadRouter := router.Group("/v1/tasks")
taskReadRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
{
taskReadRouter.GET("/:key", controller.GetTask)
taskReadRouter.GET("/:key/artifacts", controller.GetTaskArtifacts)
}
taskContentRouter := router.Group("/v1/tasks")
taskContentRouter.Use(
middleware.RouteTag("relay"),
middleware.TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
)
{
taskContentRouter.GET("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
taskContentRouter.HEAD("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
}
}
+55
View File
@@ -0,0 +1,55 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestGetTaskPluginOptionsAdminForbiddenRootAllowed(t *testing.T) {
wasMaster := common.IsMasterNode
common.IsMasterNode = true
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, authz.Init(db))
t.Cleanup(func() { common.IsMasterNode = wasMaster })
gin.SetMode(gin.TestMode)
for _, testCase := range []struct {
name string
id int
role int
wantStatus int
}{
{name: "admin", id: 2, role: common.RoleAdminUser, wantStatus: http.StatusForbidden},
{name: "root", id: 1, role: common.RoleRootUser, wantStatus: http.StatusOK},
} {
t.Run(testCase.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
context.Set("id", testCase.id)
context.Set("role", testCase.role)
middleware.RequirePermission(authz.TaskPluginBind)(context)
if !context.IsAborted() {
controller.GetTaskPluginOptions(context)
}
assert.Equal(t, testCase.wantStatus, recorder.Code)
})
}
}
@@ -0,0 +1,31 @@
package router
import (
"fmt"
"sort"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestHostProtocolRegistryDrivesProtocolRoutesOnce(t *testing.T) {
engine := gin.New()
SetTaskPluginProtocolRouter(engine)
expected := []string{
"POST /v1/responses",
"GET /v1/responses/:response_id",
"POST /v1/videos",
"GET /v1/videos/:task_id",
"GET /v1/videos/:task_id/content",
"HEAD /v1/videos/:task_id/content",
}
actual := make([]string, 0, len(engine.Routes()))
for _, route := range engine.Routes() {
actual = append(actual, fmt.Sprintf("%s %s", route.Method, route.Path))
}
sort.Strings(expected)
sort.Strings(actual)
assert.Equal(t, expected, actual)
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Gin panics at registration time when routes sharing a path position use
// different wildcard names, which unit tests that build their own routers
// never catch. Registering against a real engine is the only guard.
func TestSetTaskRouterRegistersWithoutConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
require.NotPanics(t, func() { SetTaskRouter(engine) })
routes := engine.Routes()
require.Len(t, routes, 5)
actual := make(map[string]struct{}, len(routes))
for _, route := range routes {
actual[route.Method+" "+route.Path] = struct{}{}
}
assert.Contains(t, actual, http.MethodPost+" /v1/tasks/:key")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts/:artifact_key/content")
assert.Contains(t, actual, http.MethodHead+" /v1/tasks/:key/artifacts/:artifact_key/content")
for route := range actual {
assert.NotContains(t, route, "/native/")
}
}
+14 -33
View File
@@ -8,45 +8,26 @@ import (
)
func SetVideoRouter(router *gin.Engine) {
// Video proxy: accepts either session auth (dashboard) or token auth (API clients)
videoProxyRouter := router.Group("/v1")
videoProxyRouter.Use(middleware.RouteTag("relay"))
videoProxyRouter.Use(middleware.TokenOrUserAuth())
{
videoProxyRouter.GET("/videos/:task_id/content", controller.VideoProxy)
}
videoSharedRouter := router.Group("/v1")
videoSharedRouter.Use(middleware.RouteTag("relay"))
videoSharedRouter.Use(middleware.TokenAuth())
videoSharedRouter.Use(middleware.SystemPerformanceCheck())
videoSharedRouter.POST(
"/video/generations",
middleware.PinTaskPluginEndpoint(),
middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()),
middleware.PrepareTaskPluginEndpoint(),
middleware.Distribute(),
func(c *gin.Context) {
controller.RelayTaskPluginEndpoint(c, controller.RelayTask)
},
)
videoV1Router := router.Group("/v1")
videoV1Router.Use(middleware.RouteTag("relay"))
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
videoV1Router.POST("/video/generations", controller.RelayTask)
videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch)
videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask)
}
// openai compatible API video routes
// docs: https://platform.openai.com/docs/api-reference/videos/create
{
videoV1Router.POST("/videos", controller.RelayTask)
videoV1Router.GET("/videos/:task_id", controller.RelayTaskFetch)
}
klingV1Router := router.Group("/kling/v1")
klingV1Router.Use(middleware.RouteTag("relay"))
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
{
klingV1Router.POST("/videos/text2video", controller.RelayTask)
klingV1Router.POST("/videos/image2video", controller.RelayTask)
klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTaskFetch)
klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTaskFetch)
}
// Jimeng official API routes - direct mapping to official API format
jimengOfficialGroup := router.Group("jimeng")
jimengOfficialGroup.Use(middleware.RouteTag("relay"))
jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
{
// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
jimengOfficialGroup.POST("/", controller.RelayTask)
}
}
+151
View File
@@ -0,0 +1,151 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetOpenAIVideoRouteRendersJimengTask(t *testing.T) {
gin.SetMode(gin.TestMode)
previousDB := model.DB
previousDatabaseType := common.MainDatabaseType()
previousLogDatabaseType := common.LogDatabaseType()
previousSQLitePath := common.SQLitePath
previousMasterNode := common.IsMasterNode
previousRedisEnabled := common.RedisEnabled
common.SQLitePath = t.TempDir() + "/router-video.db"
common.IsMasterNode = false
common.RedisEnabled = false
t.Setenv("SQL_DSN", "")
require.NoError(t, model.InitDB())
database := model.DB
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Token{}, &model.Channel{}, &model.Task{}))
t.Cleanup(func() {
sqlDB, closeErr := database.DB()
require.NoError(t, closeErr)
require.NoError(t, sqlDB.Close())
model.DB = previousDB
common.SetDatabaseTypes(previousDatabaseType, previousLogDatabaseType)
common.SQLitePath = previousSQLitePath
common.IsMasterNode = previousMasterNode
common.RedisEnabled = previousRedisEnabled
})
require.NoError(t, database.Create(&model.User{
Id: 91,
Username: "jimeng-fetch-user",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Quota: 100,
Group: "default",
AuthVersion: 1,
}).Error)
require.NoError(t, database.Create(&model.Token{
Id: 1,
UserId: 91,
Key: "jimengfetch",
Status: common.TokenStatusEnabled,
Name: "jimeng fetch",
ExpiredTime: -1,
UnlimitedQuota: true,
}).Error)
require.NoError(t, database.Create(&model.Channel{
Id: 17,
Type: constant.ChannelTypeJimeng,
Key: "unused",
Status: common.ChannelStatusEnabled,
Name: "jimeng fetch",
Models: "jimeng_vgfm_t2v_l20",
Group: "default",
}).Error)
task := &model.Task{
CreatedAt: 1710000000,
UpdatedAt: 1710000060,
TaskID: "task_jimeng_public",
Platform: constant.TaskPlatform("jimeng"),
UserId: 91,
Group: "default",
ChannelId: 17,
Status: model.TaskStatusSuccess,
Progress: "100%",
PrivateData: model.TaskPrivateData{
ResultURL: "data:video/mp4;base64,ZGF0YQ==",
},
}
task.SetData(map[string]any{
"code": 10000,
"data": map[string]any{
"status": "done",
"task_id": "jimeng-private-1",
"video_url": "https://cdn.example/video.mp4",
},
"message": "success",
})
require.NoError(t, database.Create(task).Error)
engine := gin.New()
SetVideoRouter(engine)
SetTaskPluginProtocolRouter(engine)
request := httptest.NewRequest(http.MethodGet, "/v1/videos/task_jimeng_public", nil)
request.Header.Set("Authorization", "Bearer sk-jimengfetch")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var response struct {
ID string `json:"id"`
Object string `json:"object"`
Status string `json:"status"`
Progress int `json:"progress"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "task_jimeng_public", response.ID)
assert.Equal(t, "video", response.Object)
assert.Equal(t, "completed", response.Status)
assert.Equal(t, 100, response.Progress)
assert.Equal(t, int64(1710000000), response.CreatedAt)
assert.Equal(t, int64(1710000060), response.CompletedAt)
assert.NotContains(t, recorder.Body.String(), "cdn.example")
assert.NotContains(t, recorder.Body.String(), "jimeng-private-1")
for _, testCase := range []struct {
name string
authorization string
query string
wantStatus int
}{
{name: "missing credential rejected", wantStatus: http.StatusUnauthorized},
{name: "access rejected", query: "?access=not-a-video-credential", wantStatus: http.StatusUnauthorized},
{name: "bearer accepted", authorization: "Bearer sk-jimengfetch", wantStatus: http.StatusOK},
} {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequest(
http.MethodGet,
"/v1/videos/task_jimeng_public/content"+testCase.query,
nil,
)
if testCase.authorization != "" {
request.Header.Set("Authorization", testCase.authorization)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
assert.Equal(t, testCase.wantStatus, recorder.Code, recorder.Body.String())
if testCase.wantStatus == http.StatusOK {
assert.Equal(t, "data", recorder.Body.String())
}
})
}
}
+17 -14
View File
@@ -19,20 +19,23 @@ type WebAssets struct {
IndexPage []byte
}
func SetWebRouter(router *gin.Engine, assets WebAssets) {
func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.HandlerFunc) {
frontendFS := common.EmbedFolder(assets.BuildFS, "web/dist")
router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
router.Use(middleware.Cache())
router.Use(static.Serve("/", frontendFS))
router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web")
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
controller.RelayNotFound(c)
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage)
})
router.NoRoute(
pluginDispatcher,
middleware.RouteTag("web"),
gzip.Gzip(gzip.DefaultCompression),
middleware.GlobalWebRateLimit(),
middleware.Cache(),
static.Serve("/", frontendFS),
func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
controller.RelayNotFound(c)
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage)
},
)
}