Files
new-api/controller/task_plugin_test.go
T
CaIon 4c34f25a49 feat(channel): bind multiple task plugins to New API channels
A New API (type 60) channel proxies another gateway that may host many
task plugins, so the channel setting gains task_extend_plugin_keys; the
single task_plugin_key stays valid there as well. Channel selection,
shared-model candidate rebinding, the task_plugin.bind permission gate,
plugin usage counting and the disable cascade (which now unbinds gateway
channels instead of disabling them) all honour the union of both fields,
and plugins may no longer claim channel type 60 in channelTypes.

Plugins declare meta.upstreams and the host injects ctx.upstream into
every driver hook context. On a type-60 channel the driver addresses its
own prefixed native routes (doubao, alibaba, kling, jimeng) and the host
sends the channel key as a Bearer header for every auth type; polling
threads the channel type through ChannelMeta and tasks keep the key
that submitted them. Only plugins declaring new_api can be bound.

The channel form offers an "Upstream task plugins" selector for type-60
channels, MultiSelect options can carry an icon, and the plugin API docs,
type declarations and schema describe the additive apiVersion 1
extension.
2026-09-20 17:51:51 +08:00

1293 lines
53 KiB
Go

package controller
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupTaskPluginControllerTest(t *testing.T) {
t.Helper()
originalDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.TaskPlugin{}, &model.Channel{}, &model.Ability{}, &model.Task{}, &model.Option{}))
model.DB = database
t.Cleanup(func() { model.DB = originalDB })
}
const lifecyclePluginSource = `
export const meta = {apiVersion: 1, key: "lifecycle-only", name: "Lifecycle", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
func taskPluginControllerTestSource(key, version string) string {
return fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, version)
}
func taskPluginControllerChannelSource(key, version string, channelType int) string {
return fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Test", version: %q, author: {name: "Test"}, channelTypes: [%d], models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, version, channelType)
}
func cleanupTaskPluginControllerRuntime(t *testing.T, key string) {
t.Helper()
t.Cleanup(func() {
jsplugin.DefaultRegistry.Unregister(key)
taskPluginSyncState.Lock()
delete(taskPluginSyncState.hashes, key)
delete(taskPluginSyncState.errors, key)
taskPluginSyncState.Unlock()
})
}
func TestDeleteThirdPartyPluginReportsAssociatedChannelsAndInFlightTasks(t *testing.T) {
setupTaskPluginControllerTest(t)
loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
baseURL := "https://example.com"
setting := `{"task_plugin_key":"lifecycle-only"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusInProgress}).Error)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}, {Key: "version", Value: "1"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/lifecycle-only/versions/1", nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"name":"linked"`)
assert.Contains(t, recorder.Body.String(), `"in_flight_count":1`)
}
func TestDisableThirdPartyPluginSupportsCascadeAndForce(t *testing.T) {
setupTaskPluginControllerTest(t)
loaded, err := jsplugin.DefaultRegistry.Register(lifecyclePluginSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("lifecycle-only") })
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: loaded.Meta.Key, APIVersion: 1, Version: "1", Source: lifecyclePluginSource, SourceHash: "hash", Enabled: true}))
baseURL := "https://example.com"
setting := `{"task_plugin_key":"lifecycle-only"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
gatewaySetting := `{"task_extend_plugin_keys":["lifecycle-only","other"]}`
gateway := model.Channel{Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "gateway", Models: "doc,gpt", Group: "default", BaseURL: &baseURL, Setting: &gatewaySetting}
require.NoError(t, gateway.Insert())
require.NoError(t, model.DB.Create(&model.Task{Platform: "lifecycle-only", Status: model.TaskStatusSubmitted}).Error)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "lifecycle-only"}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/lifecycle-only/status?cascade=true&force=true", strings.NewReader(`{"enabled":false}`))
context.Request.Header.Set("Content-Type", "application/json")
SetTaskPluginStatus(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Contains(t, recorder.Body.String(), `"disabled_channels":1`)
assert.Contains(t, recorder.Body.String(), `"unbound_channels":1`)
updated, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusManuallyDisabled, updated.Status)
updatedGateway, err := model.GetChannelById(gateway.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusEnabled, updatedGateway.Status, "a gateway channel keeps serving its other traffic")
assert.Equal(t, []string{"other"}, updatedGateway.GetSetting().TaskExtendPluginKeys, "only the disabled plugin is unbound")
}
// klingFactoryVersion returns the version declared in the embedded kling factory
// manifest so tests do not hardcode a value that moves with every plugin release.
func klingFactoryVersion(t *testing.T) string {
t.Helper()
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
match := regexp.MustCompile(`version:\s*"([^"]+)"`).FindStringSubmatch(factorySource)
require.Len(t, match, 2, "kling factory manifest must declare a version")
return match[1]
}
func setupTaskPluginFactoryDisableTest(t *testing.T) {
t.Helper()
setupTaskPluginControllerTest(t)
originalMap := common.OptionMap
common.OptionMapRWMutex.Lock()
common.OptionMap = map[string]string{}
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMapRWMutex.Lock()
common.OptionMap = originalMap
common.OptionMapRWMutex.Unlock()
})
}
func postTaskPluginStatus(t *testing.T, key, query, body string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
path := "/api/plugin/task/" + key + "/status"
if query != "" {
path += "?" + query
}
context.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
SetTaskPluginStatus(context)
return recorder
}
func listTaskPluginItem(t *testing.T, key string) taskPluginListItem {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
ListTaskPlugins(context)
var response struct {
Success bool `json:"success"`
Data []taskPluginListItem `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
if item.Meta.Key == key {
return item
}
}
t.Fatalf("task plugin %q not found", key)
return taskPluginListItem{}
}
func taskPluginOptionsHasKey(t *testing.T, key string) bool {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
GetTaskPluginOptions(context)
var response struct {
Success bool `json:"success"`
Data []struct {
Key string `json:"key"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, option := range response.Data {
if option.Key == key {
return true
}
}
return false
}
func TestDisableFactoryPluginPersistsOptionAndHidesFromBindOptions(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
const key = "kling"
recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Equal(t, []string{key}, setting.GetTaskPluginDisabledFactoryKeys())
var stored model.Option
require.NoError(t, model.DB.Where("key = ?", setting.TaskPluginDisabledFactoryKeysKey).First(&stored).Error)
assert.Equal(t, `["kling"]`, stored.Value)
item := listTaskPluginItem(t, key)
assert.Equal(t, "factory", item.Source)
assert.False(t, item.Enabled)
assert.Equal(t, "disabled", item.RuntimeStatus)
assert.False(t, taskPluginOptionsHasKey(t, key))
_, ok := jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
recorder = postTaskPluginStatus(t, key, "", `{"enabled":true}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
item = listTaskPluginItem(t, key)
assert.True(t, item.Enabled)
assert.Equal(t, "registered", item.RuntimeStatus)
assert.True(t, taskPluginOptionsHasKey(t, key))
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestDisableFactoryPluginRespectsInUseGuard(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
const key = "kling"
baseURL := "https://example.com"
channelSetting := `{"task_plugin_key":"kling"}`
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "linked-factory", Models: "doc", Group: "default", BaseURL: &baseURL, Setting: &channelSetting}
require.NoError(t, channel.Insert())
recorder := postTaskPluginStatus(t, key, "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), "task plugin is still in use")
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
_, ok := jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestDisableFactoryOverrideRowSuppressesBothLayers(t *testing.T) {
setupTaskPluginFactoryDisableTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
factoryVersion := klingFactoryVersion(t)
overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-factory-status"`, 1)
require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
require.NoError(t, syncTaskPluginsOnce())
recorder := postTaskPluginStatus(t, "kling", "", `{"enabled":false}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Equal(t, []string{"kling"}, setting.GetTaskPluginDisabledFactoryKeys())
row, err := model.GetTaskPluginVersion("kling", "")
require.NoError(t, err)
assert.False(t, row.Enabled)
item := listTaskPluginItem(t, "kling")
assert.Equal(t, "override_over_factory", item.Source)
assert.False(t, item.Enabled)
assert.Equal(t, "disabled", item.RuntimeStatus)
assert.False(t, taskPluginOptionsHasKey(t, "kling"))
_, ok := jsplugin.DefaultRegistry.Get("kling")
assert.False(t, ok, "factory built-in must not keep serving after the plugin is switched off")
recorder = postTaskPluginStatus(t, "kling", "", `{"enabled":true}`)
assert.Contains(t, recorder.Body.String(), `"success":true`)
assert.Empty(t, setting.GetTaskPluginDisabledFactoryKeys())
row, err = model.GetTaskPluginVersion("kling", "")
require.NoError(t, err)
assert.True(t, row.Enabled)
got, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
assert.Equal(t, loaded.Meta.Version, got.Meta.Version)
assert.Equal(t, "registered", listTaskPluginItem(t, "kling").RuntimeStatus)
// Regression: with every kling layer off, a plugin whose model differs from
// a built-in kling model only by case must upload without a routing conflict.
recorder = postTaskPluginStatus(t, "kling", "", `{"enabled":false}`)
require.Contains(t, recorder.Body.String(), `"success":true`)
cleanupTaskPluginControllerRuntime(t, "kling-shadow")
const shadowSource = `
export const meta = {apiVersion: 1, key: "kling-shadow", name: "Shadow", version: "1.0.0", author: {name: "Test"}, models: ["KLING-V1"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
body, err := common.Marshal(map[string]any{"source": shadowSource})
require.NoError(t, err)
uploadRecorder := httptest.NewRecorder()
uploadContext, _ := gin.CreateTestContext(uploadRecorder)
uploadContext.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
uploadContext.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(uploadContext)
assert.Contains(t, uploadRecorder.Body.String(), `"success":true`)
_, ok = jsplugin.DefaultRegistry.Get("kling-shadow")
assert.True(t, ok)
}
func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task", nil)
ListTaskPlugins(context)
var response struct {
Success bool `json:"success"`
Data []taskPluginListItem `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
var factoryItem *taskPluginListItem
for i := range response.Data {
if response.Data[i].Meta.Key == "kling" {
factoryItem = &response.Data[i]
break
}
}
require.NotNil(t, factoryItem)
assert.Equal(t, "factory", factoryItem.Source)
assert.Equal(t, "registered", factoryItem.RuntimeStatus)
assert.NotEmpty(t, factoryItem.SourceHash)
}
func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
setupTaskPluginControllerTest(t)
originalEnabled := constant.TaskPluginEnabled
jsplugin.DefaultRegistry.SetEnabled(false)
t.Cleanup(func() {
constant.TaskPluginEnabled = originalEnabled
jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
})
assert.False(t, taskPluginOptionsHasKey(t, "kling"))
item := listTaskPluginItem(t, "kling")
assert.Equal(t, "factory", item.Source)
assert.Equal(t, "kling", item.Meta.Key)
}
func TestGetTaskPluginOptionsIncludesDescriptionUsageSchemaIconBaseURLAndChannelTypes(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "usage-options-probe"
source := `
export const meta = {
apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
icon: "text:UO", baseUrl: "http://localhost:9000/",
channelTypes: [1990, 1991],
upstreams: ["vendor", "new_api"],
models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Video generation unit price"}},
usageProfiles: [{models:["usage-options-model"],schema:{image_count:{type:"number",unit:"count"}}}]
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
GetTaskPluginOptions(context)
var response struct {
Success bool `json:"success"`
Data []struct {
Key string `json:"key"`
Description jsplugin.LocalizedText `json:"description"`
Icon string `json:"icon"`
BaseURL string `json:"baseUrl"`
ChannelTypes []int `json:"channelTypes"`
Upstreams []string `json:"upstreams"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
UsageProfiles []jsplugin.UsageProfile `json:"usageProfiles"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, option := range response.Data {
if option.Key != key {
continue
}
assert.Equal(t, jsplugin.LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, option.Description)
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Video generation unit price", option.UsageSchema["seconds"].Description["en"])
require.Len(t, option.UsageProfiles, 1)
assert.Equal(t, []string{"usage-options-model"}, option.UsageProfiles[0].Models)
assert.Equal(t, "count", option.UsageProfiles[0].Schema["image_count"].Unit)
assert.Equal(t, "text:UO", option.Icon)
assert.Equal(t, []int{1990, 1991}, option.ChannelTypes)
assert.Equal(t, []string{"vendor", "new_api"}, option.Upstreams, "the New API channel form lists only plugins declaring new_api")
assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return
}
t.Fatal("task plugin option not found")
}
func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
setupTaskPluginControllerTest(t)
factorySource, err := plugins.Source("kling")
require.NoError(t, err)
factoryVersion := klingFactoryVersion(t)
overrideSource := strings.Replace(factorySource, `version: "`+factoryVersion+`"`, `version: "`+factoryVersion+`-test-override"`, 1)
require.NotEqual(t, factorySource, overrideSource, "factory version marker must be found in kling source")
loaded, err := jsplugin.DefaultRegistry.Register(overrideSource, jsplugin.Options{Key: "kling", Version: "test-override"})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
plugin := model.TaskPlugin{
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
}
require.NoError(t, model.SaveTaskPlugin(&plugin))
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: loaded.Meta.Version}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/"+loaded.Meta.Version, nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
versions, err := model.ListTaskPluginVersions("kling")
require.NoError(t, err)
assert.Empty(t, versions)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get("kling")
require.True(t, ok)
assert.NotEqual(t, loaded.Meta.Version, runtimePlugin.Meta.Version)
}
func TestDeleteActiveTaskPluginPromotesEnabledVersionInRuntime(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "delete-promote-probe"
cleanupTaskPluginControllerRuntime(t, key)
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "hash-v1"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/"+key+"/versions/1.0.0", nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
active, err := model.GetTaskPluginVersion(key, "")
require.NoError(t, err)
assert.Equal(t, "2.0.0", active.Version)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, "hash-v2", syncedHash)
assert.False(t, hasSyncError)
}
func TestUploadTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "upload-sync-probe"
cleanupTaskPluginControllerRuntime(t, key)
source := taskPluginControllerTestSource(key, "1.0.0")
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "stale-hash"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
body, err := common.Marshal(map[string]any{"source": source})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
stored, err := model.GetTaskPluginVersion(key, "")
require.NoError(t, err)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, stored.SourceHash, syncedHash)
assert.False(t, hasSyncError)
}
func TestActivateTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "activate-sync-probe"
cleanupTaskPluginControllerRuntime(t, key)
v1Source := taskPluginControllerTestSource(key, "1.0.0")
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
}))
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
require.NoError(t, err)
taskPluginSyncState.Lock()
taskPluginSyncState.hashes[key] = "hash-v1"
taskPluginSyncState.errors[key] = "stale compile error"
taskPluginSyncState.Unlock()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/"+key+"/activate", strings.NewReader(`{"version":"2.0.0"}`))
context.Request.Header.Set("Content-Type", "application/json")
ActivateTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":true`)
runtimePlugin, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "2.0.0", runtimePlugin.Meta.Version)
taskPluginSyncState.Lock()
syncedHash := taskPluginSyncState.hashes[key]
_, hasSyncError := taskPluginSyncState.errors[key]
taskPluginSyncState.Unlock()
assert.Equal(t, "hash-v2", syncedHash)
assert.False(t, hasSyncError)
}
func TestSyncTaskPluginsPublishesOneGenerationForWholeBatch(t *testing.T) {
setupTaskPluginControllerTest(t)
firstKey := "batch-sync-first"
secondKey := "batch-sync-second"
cleanupTaskPluginControllerRuntime(t, firstKey)
cleanupTaskPluginControllerRuntime(t, secondKey)
firstSource := taskPluginControllerTestSource(firstKey, "1.0.0")
secondSource := taskPluginControllerTestSource(secondKey, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: firstSource, SourceHash: "first-hash", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: secondSource, SourceHash: "second-hash", Enabled: true,
}))
before := jsplugin.DefaultRegistry.Generation().Number
SyncTaskPluginsOnce()
assert.Equal(t, before+1, jsplugin.DefaultRegistry.Generation().Number)
_, firstRegistered := jsplugin.DefaultRegistry.Get(firstKey)
_, secondRegistered := jsplugin.DefaultRegistry.Get(secondKey)
assert.True(t, firstRegistered)
assert.True(t, secondRegistered)
published := jsplugin.DefaultRegistry.Generation()
SyncTaskPluginsOnce()
assert.Same(t, published, jsplugin.DefaultRegistry.Generation())
}
func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-revision-probe"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
v1Source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: v1Source, SourceHash: "runtime-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
localGeneration := jsplugin.DefaultRegistry.Generation().Number
taskPluginSyncState.Lock()
syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
taskPluginSyncState.Unlock()
v2Source := taskPluginControllerTestSource(key, "2.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "2.0.0",
Source: v2Source, SourceHash: "runtime-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, localGeneration, response.Data.CurrentGeneration)
assert.NotZero(t, response.Data.GenerationPublishedAt)
assert.NotEqual(t, syncedRevision, response.Data.DatabaseRevision)
assert.Equal(t, "success", response.Data.LastRebuild.Status)
assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
assert.Empty(t, response.Data.PluginErrors)
active, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
assert.Equal(t, "1.0.0", active.Meta.Version)
}
func TestTaskPluginRuntimeReportsPluginLevelCompileErrors(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-error-probe"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: "export const meta = {", SourceHash: "broken-source", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, "partial", response.Data.LastRebuild.Status)
assert.Equal(t, response.Data.DatabaseRevision, response.Data.LastRebuild.DatabaseRevision)
assert.GreaterOrEqual(t, response.Data.LastRebuild.PluginErrorCount, 1)
assert.NotEmpty(t, response.Data.PluginErrors[key])
_, registered := jsplugin.DefaultRegistry.Get(key)
assert.False(t, registered)
}
func TestTaskPluginRuntimeSurvivesDatabaseSyncFailure(t *testing.T) {
setupTaskPluginControllerTest(t)
key := "runtime-database-failure"
cleanupTaskPluginControllerRuntime(t, key)
taskPluginSyncState.Lock()
previousRebuild := taskPluginSyncState.lastRebuild
taskPluginSyncState.Unlock()
t.Cleanup(func() {
taskPluginSyncState.Lock()
taskPluginSyncState.lastRebuild = previousRebuild
taskPluginSyncState.Unlock()
})
source := taskPluginControllerTestSource(key, "1.0.0")
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: key, APIVersion: 1, Version: "1.0.0",
Source: source, SourceHash: "runtime-database-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
generation := jsplugin.DefaultRegistry.Generation().Number
taskPluginSyncState.Lock()
syncedRevision := taskPluginSyncState.lastRebuild.DatabaseRevision
taskPluginSyncState.Unlock()
sqlDatabase, err := model.DB.DB()
require.NoError(t, err)
require.NoError(t, sqlDatabase.Close())
require.Error(t, syncTaskPluginsOnce())
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/runtime/status", nil)
GetTaskPluginRuntime(context)
var response struct {
Success bool `json:"success"`
Data taskPluginRuntimeStatus `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, generation, response.Data.CurrentGeneration)
assert.Equal(t, syncedRevision, response.Data.DatabaseRevision)
assert.Equal(t, "database snapshot unavailable", response.Data.DatabaseError)
assert.Equal(t, "failed", response.Data.LastRebuild.Status)
assert.Equal(t, syncedRevision, response.Data.LastRebuild.DatabaseRevision)
assert.Contains(t, response.Data.LastRebuild.Error, "sync task plugins")
}
func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *testing.T) {
setupTaskPluginControllerTest(t)
pluginKey := "sync-retained-plugin"
ownerKey := "sync-retained-owner"
cleanupTaskPluginControllerRuntime(t, pluginKey)
cleanupTaskPluginControllerRuntime(t, ownerKey)
v1Source := taskPluginControllerChannelSource(pluginKey, "1.0.0", 9001)
v2Source := taskPluginControllerChannelSource(pluginKey, "2.0.0", 9002)
ownerSource := taskPluginControllerChannelSource(ownerKey, "1.0.0", 9002)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "retained-v1", Enabled: true,
}))
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: ownerSource, SourceHash: "owner-v1", Enabled: true,
}))
require.NoError(t, syncTaskPluginsOnce())
incumbent, ok := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, ok)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "retained-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(pluginKey, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
rejectedGeneration := jsplugin.DefaultRegistry.Generation()
for range 2 {
require.NoError(t, syncTaskPluginsOnce())
active, found := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, found)
assert.Same(t, incumbent, active)
assert.Equal(t, "2.0.0", jsplugin.DefaultRegistry.OverridePlugins()[pluginKey].Meta.Version)
assert.Same(t, incumbent, jsplugin.DefaultRegistry.ActiveOverridePlugins()[pluginKey])
assert.Contains(t, jsplugin.DefaultRegistry.RoutingErrors()[pluginKey], "channelType 9002 conflicts")
taskPluginSyncState.Lock()
assert.Equal(t, "retained-v2", taskPluginSyncState.hashes[pluginKey])
taskPluginSyncState.Unlock()
assert.Same(t, rejectedGeneration, jsplugin.DefaultRegistry.Generation())
}
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
Key: ownerKey, APIVersion: 1, Version: "2.0.0",
Source: taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003), SourceHash: "owner-v2", Enabled: true,
}))
require.NoError(t, model.ActivateTaskPlugin(ownerKey, "2.0.0"))
require.NoError(t, syncTaskPluginsOnce())
active, ok := jsplugin.DefaultRegistry.Get(pluginKey)
require.True(t, ok)
assert.Equal(t, "2.0.0", active.Meta.Version)
assert.NotContains(t, jsplugin.DefaultRegistry.RoutingErrors(), pluginKey)
}
const dryRunPluginSource = `
export const meta = {apiVersion: 1, key: "dryrun-probe", name: "DryRun", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"};
export function buildSubmitRequest(payload) {
if (!payload || !payload.model) { throw new Error("model required"); }
return {model: payload.model};
}
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
export const native = { info: function(ctx, task) { return "task:" + task.id; } };
`
func runTaskPluginDryRun(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "dryrun-probe"}}
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task/dryrun-probe/dryrun", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
DryRunTaskPlugin(context)
return recorder
}
func TestDryRunTaskPluginExecutesHookAndRendererMember(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
hookRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{"model":"doc-1"}]}`)
assert.Contains(t, hookRecorder.Body.String(), `"success":true`)
assert.Contains(t, hookRecorder.Body.String(), `"model":"doc-1"`)
memberRecorder := runTaskPluginDryRun(t, `{"hook":"native","member":"info","args":[{}, {"id":"t-1"}]}`)
assert.Contains(t, memberRecorder.Body.String(), `"success":true`)
assert.Contains(t, memberRecorder.Body.String(), "task:t-1")
}
func TestDryRunTaskPluginReportsUnknownHook(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
recorder := runTaskPluginDryRun(t, `{"hook":"missingHook"}`)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), `plugin export \"missingHook\" not found`)
}
func TestDryRunTaskPluginSurfacesBadArgumentErrors(t *testing.T) {
setupTaskPluginControllerTest(t)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: "dryrun-probe", APIVersion: 1, Version: "1.0.0", Source: dryRunPluginSource, SourceHash: "hash", Enabled: true}))
malformedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{`)
assert.Contains(t, malformedRecorder.Body.String(), `"success":false`)
rejectedRecorder := runTaskPluginDryRun(t, `{"hook":"buildSubmitRequest","args":[{}]}`)
assert.Contains(t, rejectedRecorder.Body.String(), `"success":false`)
assert.Contains(t, rejectedRecorder.Body.String(), "model required")
}
func TestUploadTaskPluginPreflightConflict(t *testing.T) {
setupTaskPluginControllerTest(t)
enabledFalse := false
tests := []struct {
name string
key string
enabled *bool
force bool
wantSuccess bool
wantError string
}{
{
name: "enabled conflict rejected",
key: "preflight-reject",
wantError: "channelType 50 conflicts",
},
{
name: "force saves despite conflict",
key: "preflight-force",
force: true,
wantSuccess: true,
},
{
name: "disabled skips preflight",
key: "preflight-disabled",
enabled: &enabledFalse,
wantSuccess: true,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
cleanupTaskPluginControllerRuntime(t, testCase.key)
source := taskPluginControllerChannelSource(testCase.key, "1.0.0", 50)
payload := map[string]any{"source": source}
if testCase.enabled != nil {
payload["enabled"] = *testCase.enabled
}
if testCase.force {
payload["force"] = true
}
body, err := common.Marshal(payload)
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
if testCase.wantSuccess {
assert.Contains(t, recorder.Body.String(), `"success":true`)
_, err = model.GetTaskPluginVersion(testCase.key, "")
require.NoError(t, err)
return
}
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantError)
assert.Contains(t, recorder.Body.String(), "kling")
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestUploadTaskPluginLocalizesUnknownMetaField(t *testing.T) {
require.NoError(t, i18n.Init())
body, err := common.Marshal(map[string]any{
"source": `export const meta = { futureField: true };`,
})
require.NoError(t, err)
for _, tc := range []struct {
language string
message string
}{
{"en", `Plugin metadata contains an unknown field "futureField". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.`},
{"zh-CN", "插件元数据包含未知字段“futureField”。如果插件来自官方市场,可能需要更高版本的 new-api。请尝试更新 new-api 后重新安装插件。"},
{"zh-TW", "外掛中繼資料包含未知欄位「futureField」。如果外掛來自官方市集,可能需要較新版本的 new-api。請嘗試更新 new-api 後重新安裝外掛。"},
} {
t.Run(tc.language, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Accept-Language", tc.language)
UploadTaskPlugin(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, tc.message, response.Message)
})
}
}
func TestUploadTaskPluginRejectsMetaViolatingV1Schema(t *testing.T) {
setupTaskPluginControllerTest(t)
cases := []struct {
name string
meta string
expectedError string
}{
{
name: "key with uppercase characters",
meta: `{apiVersion: 1, key: "Bad-Key", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
expectedError: "plugin meta key must match",
},
{
name: "version that is not semver",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "one", author: {name: "Test"}, models: ["doc-1"], fetchMode: "per_task"}`,
expectedError: "plugin meta version must be semver",
},
{
name: "unsupported fetch mode",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: ["doc-1"], fetchMode: "sometimes"}`,
expectedError: "plugin meta fetchMode must be per_task or batch",
},
{
name: "empty model list",
meta: `{apiVersion: 1, key: "bad-plugin", name: "Bad", version: "1.0.0", author: {name: "Test"}, models: [], fetchMode: "per_task"}`,
expectedError: "plugin meta models must contain at least one model",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
source := "export const meta = " + testCase.meta + `;
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
body, err := common.Marshal(map[string]any{"source": source})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.expectedError)
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
for _, query := range []string{"", "?force=true"} {
t.Run(query, func(t *testing.T) {
setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0"+query, nil)
DeleteTaskPluginVersion(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
_, ok := jsplugin.DefaultRegistry.Get("kling")
assert.True(t, ok)
})
}
}
func TestUploadTaskPluginSourceSha256(t *testing.T) {
setupTaskPluginControllerTest(t)
tests := []struct {
name string
key string
withHash bool
hash string
wantSuccess bool
wantError string
}{
{
name: "matching hash succeeds",
key: "sha256-match",
withHash: true,
wantSuccess: true,
},
{
name: "mismatching hash rejected",
key: "sha256-mismatch",
withHash: true,
hash: "deadbeef",
wantError: "plugin source sha256 mismatch",
},
{
name: "absent field unchanged",
key: "sha256-absent",
wantSuccess: true,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
cleanupTaskPluginControllerRuntime(t, testCase.key)
source := taskPluginControllerTestSource(testCase.key, "1.0.0")
payload := map[string]any{"source": source}
if testCase.withHash {
hash := testCase.hash
if hash == "" {
hash = " " + strings.ToUpper(fmt.Sprintf("%x", sha256.Sum256([]byte(source)))) + " "
}
payload["sourceSha256"] = hash
}
body, err := common.Marshal(payload)
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", strings.NewReader(string(body)))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
if testCase.wantSuccess {
assert.Contains(t, recorder.Body.String(), `"success":true`)
_, err = model.GetTaskPluginVersion(testCase.key, "")
require.NoError(t, err)
return
}
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantError)
var count int64
require.NoError(t, model.DB.Model(&model.TaskPlugin{}).Where("key = ?", testCase.key).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func setupTaskPluginMarketplaceSourcesTest(t *testing.T) {
t.Helper()
setupTaskPluginControllerTest(t)
originalMap := common.OptionMap
common.OptionMapRWMutex.Lock()
common.OptionMap = map[string]string{}
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
common.OptionMapRWMutex.Lock()
common.OptionMap = originalMap
common.OptionMapRWMutex.Unlock()
})
}
func TestGetTaskPluginMarketplaceSourcesDefaultWhenUnset(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
GetTaskPluginMarketplaceSources(context)
var response struct {
Success bool `json:"success"`
Data []setting.TaskPluginMarketplaceSource `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
require.Equal(t, []setting.TaskPluginMarketplaceSource{
{Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
{Name: "GitHub", IndexURL: "https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json"},
}, response.Data)
var count int64
require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
assert.Zero(t, count)
}
func TestUpdateTaskPluginMarketplaceSourcesRoundTrip(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
payload := []setting.TaskPluginMarketplaceSource{
{Name: "Mirror", IndexURL: "https://example.com/plugins/index.json"},
{Name: "Official", IndexURL: "https://www.newapi.ai/api/v1/plugins/index.json"},
}
body, err := common.Marshal(payload)
require.NoError(t, err)
putRecorder := httptest.NewRecorder()
putContext, _ := gin.CreateTestContext(putRecorder)
putContext.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(string(body)))
putContext.Request.Header.Set("Content-Type", "application/json")
UpdateTaskPluginMarketplaceSources(putContext)
assert.Contains(t, putRecorder.Body.String(), `"success":true`)
getRecorder := httptest.NewRecorder()
getContext, _ := gin.CreateTestContext(getRecorder)
getContext.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/marketplace/sources", nil)
GetTaskPluginMarketplaceSources(getContext)
var response struct {
Success bool `json:"success"`
Data []setting.TaskPluginMarketplaceSource `json:"data"`
}
require.NoError(t, common.Unmarshal(getRecorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, payload, response.Data)
}
func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
setupTaskPluginMarketplaceSourcesTest(t)
tests := []struct {
name string
body string
wantErr string
}{
{
name: "empty name",
body: `[{"name":"","index_url":"https://example.com/index.json"}]`,
wantErr: "marketplace source name is required",
},
{
name: "invalid URL",
body: `[{"name":"Local","index_url":"not-a-url"}]`,
wantErr: "marketplace source index_url must be an absolute http(s) URL",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPut, "/api/plugin/task/marketplace/sources", strings.NewReader(testCase.body))
context.Request.Header.Set("Content-Type", "application/json")
UpdateTaskPluginMarketplaceSources(context)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), testCase.wantErr)
assert.Empty(t, common.OptionMap[setting.TaskPluginMarketplaceSourcesKey])
var count int64
require.NoError(t, model.DB.Model(&model.Option{}).Where("key = ?", setting.TaskPluginMarketplaceSourcesKey).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
setupTaskPluginControllerTest(t)
const website = "https://example.com/plugins"
keys := []string{"display-low", "display-zero", "display-beta", "display-alpha"}
priorities := []int{-10, 0, 50, 50}
for i, key := range keys {
source := strings.Replace(taskPluginControllerTestSource(key, "1.0.0"), "apiVersion: 1,", fmt.Sprintf("apiVersion: 1, sortPriority: %d, website: %q,", priorities[i], website), 1)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
cleanupTaskPluginControllerRuntime(t, key)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: source, Enabled: true}))
}
for _, endpoint := range []string{"list", "options"} {
t.Run(endpoint, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/", nil)
var metas []jsplugin.Meta
if endpoint == "list" {
ListTaskPlugins(context)
var response struct {
Success bool
Data []taskPluginListItem
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
metas = append(metas, item.Meta)
}
} else {
GetTaskPluginOptions(context)
var response struct {
Success bool
Data []jsplugin.Meta
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
metas = response.Data
}
var ordered []string
for _, meta := range metas {
if strings.HasPrefix(meta.Key, "display-") {
ordered = append(ordered, meta.Key)
assert.Equal(t, website, meta.Website)
}
}
assert.Equal(t, []string{"display-alpha", "display-beta", "display-zero", "display-low"}, ordered)
})
}
}
func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "icon-sidecar"
source := taskPluginControllerTestSource(key, "1.0.0")
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
svgIcon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4"/></svg>`))
upload := func(icon string) *httptest.ResponseRecorder {
body, err := common.Marshal(map[string]any{"source": source, "icon": icon})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
rejected := upload("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>`)))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "script")
_, err := model.GetTaskPluginVersion(key, "1.0.0")
require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a rejected icon must not store the plugin")
accepted := upload(svgIcon)
require.Contains(t, accepted.Body.String(), `"success":true`)
assert.Contains(t, accepted.Body.String(), `"has_icon":true`)
assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
stored, err := model.GetTaskPluginVersion(key, "1.0.0")
require.NoError(t, err)
assert.Equal(t, svgIcon, stored.Icon)
item := listTaskPluginItem(t, key)
assert.True(t, item.HasIcon)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/"+key+"/icon", nil)
GetTaskPluginIcon(context)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "image/svg+xml", recorder.Header().Get("Content-Type"))
assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
assert.Contains(t, recorder.Body.String(), "<circle")
missing := httptest.NewRecorder()
context, _ = gin.CreateTestContext(missing)
context.Params = gin.Params{{Key: "key", Value: "no-such-plugin"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/no-such-plugin/icon", nil)
GetTaskPluginIcon(context)
assert.Equal(t, http.StatusNotFound, missing.Code)
}