Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
1232 lines
52 KiB
Go
1232 lines
52 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestRegistryOverrideTakesPrecedenceOverFactory(t *testing.T) {
|
|
registry := NewRegistry()
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
|
|
|
plugin, ok := registry.Get("test")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
|
|
}
|
|
|
|
func TestRegistryUnregisterFallsBackToFactory(t *testing.T) {
|
|
registry := NewRegistry()
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
|
|
|
registry.Unregister("test")
|
|
|
|
plugin, ok := registry.Get("test")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
|
|
}
|
|
|
|
func TestRegistrySnapshotSeparatesLayersWithoutExposingEntries(t *testing.T) {
|
|
registry := NewRegistry()
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
|
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
|
|
|
snapshot := registry.Snapshot()
|
|
|
|
require.Len(t, snapshot.Factory, 1)
|
|
require.Len(t, snapshot.Override, 1)
|
|
assert.Equal(t, "1.0.0-factory", snapshot.Factory[0].Version)
|
|
assert.Equal(t, "1.0.0-override", snapshot.Override[0].Version)
|
|
snapshot.Override[0].Version = "changed"
|
|
plugin, ok := registry.Get("test")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
|
|
}
|
|
|
|
func TestRegistryRejectsPluginKeyLongerThanTaskPlatformColumn(t *testing.T) {
|
|
source := `export const meta = {apiVersion: 1, key: "1234567890123456789012345678901", name: "Long", version: "1", author: {name: "Test"}};`
|
|
_, err := NewRegistry().Register(source, Options{})
|
|
require.ErrorContains(t, err, "must not exceed 30 characters")
|
|
}
|
|
|
|
func TestValidateV1MetaEnforcesTaskPluginKeyLength(t *testing.T) {
|
|
meta := Meta{APIVersion: 1, Key: strings.Repeat("a", 30), Name: "Test", Version: "1.0.0", Author: AuthorMeta{Name: "Test"}, Models: []string{"model"}, FetchMode: "per_task"}
|
|
require.NoError(t, ValidateV1Meta(meta))
|
|
|
|
meta.Key += "a"
|
|
require.ErrorContains(t, ValidateV1Meta(meta), "must not exceed 30 characters")
|
|
}
|
|
|
|
func TestRegistryDecodesAndValidatesIcon(t *testing.T) {
|
|
absent, err := CompilePlugin(routingTestPluginSource("icon-absent", 0, `["model"]`, "", ""), Options{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, absent.Meta.Icon)
|
|
|
|
accepted, err := CompilePlugin(routingTestPluginSource("icon-ok", 0, `["model"]`, `icon: "Sora.Color",`, ""), Options{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Sora.Color", accepted.Meta.Icon)
|
|
|
|
_, err = NewRegistry().Register(routingTestPluginSource("icon-long", 0, `["model"]`, `icon: "`+strings.Repeat("a", 129)+`",`, ""), Options{})
|
|
require.ErrorContains(t, err, "must not exceed 128 characters")
|
|
|
|
_, err = NewRegistry().Register(`
|
|
export const meta = {
|
|
apiVersion: 1, key: "icon-type", name: "Icon", version: "1.0.0", author: {name: "Test"},
|
|
models: ["model"], fetchMode: "per_task", icon: 1
|
|
};
|
|
export function buildSubmitRequest() { return {}; }
|
|
export function parseSubmitResponse() { return {}; }
|
|
export function buildQueryRequest() { return {}; }
|
|
export function parseTaskResult() { return {}; }
|
|
`, Options{})
|
|
require.ErrorContains(t, err, "must be a string")
|
|
|
|
_, err = NewRegistry().Register(routingTestPluginSource("icon-control", 0, `["model"]`, "icon: \"Sora\\u0000.Color\",", ""), Options{})
|
|
require.ErrorContains(t, err, "must not contain control characters")
|
|
}
|
|
|
|
func TestRegistryRequiresValidPluginAuthor(t *testing.T) {
|
|
missing := strings.Replace(
|
|
routingTestPluginSource("missing-author", 0, `["model"]`, "", ""),
|
|
`author: {name: "Test"},`,
|
|
"",
|
|
1,
|
|
)
|
|
_, err := CompilePlugin(missing, Options{})
|
|
require.ErrorContains(t, err, "author must be an object")
|
|
|
|
meta := Meta{
|
|
APIVersion: 1,
|
|
Key: "author-url",
|
|
Name: "Author URL",
|
|
Version: "1.0.0",
|
|
Author: AuthorMeta{Name: "Test", URL: "ftp://example.com/profile"},
|
|
Models: []string{"model"},
|
|
FetchMode: "per_task",
|
|
}
|
|
require.ErrorContains(t, ValidateV1Meta(meta), "absolute HTTP(S)")
|
|
meta.Author.URL = "https://example.com/profile"
|
|
require.NoError(t, ValidateV1Meta(meta))
|
|
}
|
|
|
|
func TestRegistryRequiresArtifactHooksAsPair(t *testing.T) {
|
|
for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
|
|
t.Run(hook, func(t *testing.T) {
|
|
source := routingTestPluginSource(
|
|
"artifact-hook-pair",
|
|
0,
|
|
`["model"]`,
|
|
"",
|
|
"export function "+hook+"() { return []; }",
|
|
)
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, "must export listArtifacts and buildContentRequest together")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryRejectsRemovedNativeRoutingFields(t *testing.T) {
|
|
for _, field := range []string{"submitPaths", "actions"} {
|
|
t.Run(field, func(t *testing.T) {
|
|
source := `
|
|
export const meta = {
|
|
apiVersion: 1, key: "removed-field", name: "Removed", version: "1.0.0", author: {name: "Test"},
|
|
models: ["model"], fetchMode: "per_task", ` + field + `: []
|
|
};
|
|
export function buildSubmitRequest() { return {}; }
|
|
export function parseSubmitResponse() { return {}; }
|
|
export function buildQueryRequest() { return {}; }
|
|
export function parseTaskResult() { return {}; }
|
|
`
|
|
_, err := NewRegistry().Register(source, Options{})
|
|
require.ErrorContains(t, err, "declare routes instead")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryDecodesAndValidatesUsageSchema(t *testing.T) {
|
|
withoutSchema, err := CompilePlugin(
|
|
routingTestPluginSource("usage-schema-absent", 0, `["model"]`, "", ""),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, withoutSchema.Meta.UsageSchema)
|
|
|
|
valid := routingTestPluginSource(
|
|
"usage-schema",
|
|
0,
|
|
`["model"]`,
|
|
`usageSchema: {
|
|
duration: {type: "number", unit: "second", description: "Generated video duration."},
|
|
count: {type: "number", unit: "count"},
|
|
tokens: {type: "number", unit: "token", description: "Upstream billing tokens."},
|
|
credits: {type: "number", unit: "credit", description: "Vendor resource-pack units."},
|
|
mode: {enum: ["std", "pro"], description: "Provider quality tier."},
|
|
generate_audio: {type: "boolean", description: "Whether audio is generated."},
|
|
},
|
|
usageExamples: [{label: "std · 1s", facts: {duration: 1, count: 1, tokens: 1, credits: 1, mode: "std", generate_audio: true}}],`,
|
|
"",
|
|
)
|
|
plugin, err := CompilePlugin(valid, Options{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "number", plugin.Meta.UsageSchema["duration"].Type)
|
|
assert.Equal(t, "second", plugin.Meta.UsageSchema["duration"].Unit)
|
|
assert.Equal(t, LocalizedText{"en": "Generated video duration."}, plugin.Meta.UsageSchema["duration"].Description)
|
|
assert.Equal(t, "number", plugin.Meta.UsageSchema["count"].Type)
|
|
assert.Equal(t, "count", plugin.Meta.UsageSchema["count"].Unit)
|
|
assert.Equal(t, "number", plugin.Meta.UsageSchema["tokens"].Type)
|
|
assert.Equal(t, "token", plugin.Meta.UsageSchema["tokens"].Unit)
|
|
assert.Equal(t, LocalizedText{"en": "Upstream billing tokens."}, plugin.Meta.UsageSchema["tokens"].Description)
|
|
assert.Equal(t, "number", plugin.Meta.UsageSchema["credits"].Type)
|
|
assert.Equal(t, "credit", plugin.Meta.UsageSchema["credits"].Unit)
|
|
assert.Equal(t, LocalizedText{"en": "Vendor resource-pack units."}, plugin.Meta.UsageSchema["credits"].Description)
|
|
assert.Equal(t, []string{"std", "pro"}, plugin.Meta.UsageSchema["mode"].Enum)
|
|
assert.Equal(t, LocalizedText{"en": "Provider quality tier."}, plugin.Meta.UsageSchema["mode"].Description)
|
|
assert.Equal(t, "boolean", plugin.Meta.UsageSchema["generate_audio"].Type)
|
|
assert.Equal(t, LocalizedText{"en": "Whether audio is generated."}, plugin.Meta.UsageSchema["generate_audio"].Description)
|
|
require.Len(t, plugin.Meta.UsageExamples, 1)
|
|
assert.Equal(t, "std · 1s", plugin.Meta.UsageExamples[0].Label)
|
|
assert.Equal(t, int64(1), plugin.Meta.UsageExamples[0].Facts["tokens"])
|
|
|
|
tests := []struct {
|
|
name string
|
|
declaration string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "unsupported numeric unit",
|
|
declaration: `{type: "number", unit: "minute"}`,
|
|
expectedError: "unit must be second, count, token, or credit",
|
|
},
|
|
{
|
|
name: "boolean cannot mix unit",
|
|
declaration: `{type: "boolean", unit: "second"}`,
|
|
expectedError: "cannot combine boolean with unit",
|
|
},
|
|
{
|
|
name: "enum cannot mix numeric shape",
|
|
declaration: `{type: "number", unit: "second", enum: ["std"]}`,
|
|
expectedError: "cannot combine enum with type or unit",
|
|
},
|
|
{
|
|
name: "enum values must be unique",
|
|
declaration: `{enum: ["std", "std"]}`,
|
|
expectedError: "enum values must be unique",
|
|
},
|
|
{
|
|
name: "enum must not be empty",
|
|
declaration: `{enum: []}`,
|
|
expectedError: "enum must contain at least one value",
|
|
},
|
|
{
|
|
name: "empty enum cannot be hidden in numeric shape",
|
|
declaration: `{type: "number", unit: "second", enum: []}`,
|
|
expectedError: "cannot combine enum with type or unit",
|
|
},
|
|
{
|
|
name: "unknown property",
|
|
declaration: `{type: "number", unit: "second", maximum: 5}`,
|
|
expectedError: `unknown property "maximum"`,
|
|
},
|
|
{
|
|
name: "description must be a string or object",
|
|
declaration: `{type: "number", unit: "second", description: 5}`,
|
|
expectedError: "description must be a string or object",
|
|
},
|
|
{
|
|
name: "description is bounded",
|
|
declaration: `{type: "number", unit: "second", description: "` + strings.Repeat("x", 257) + `"}`,
|
|
expectedError: "description must not exceed 256 characters",
|
|
},
|
|
}
|
|
for _, testCase := range tests {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
source := routingTestPluginSource(
|
|
"invalid-usage-schema",
|
|
0,
|
|
`["model"]`,
|
|
`usageSchema: {value: `+testCase.declaration+`},`,
|
|
"",
|
|
)
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, testCase.expectedError)
|
|
})
|
|
}
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
metaFields string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "explicit null",
|
|
metaFields: `usageSchema: null,`,
|
|
expectedError: "usageSchema must be an object",
|
|
},
|
|
{
|
|
name: "leading whitespace in key",
|
|
metaFields: `usageSchema: {" duration": {type: "number", unit: "second"}},`,
|
|
expectedError: "keys must be non-empty canonical names",
|
|
},
|
|
{
|
|
name: "trailing whitespace in key",
|
|
metaFields: `usageSchema: {"duration ": {type: "number", unit: "second"}},`,
|
|
expectedError: "keys must be non-empty canonical names",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
source := routingTestPluginSource(
|
|
"invalid-usage-schema",
|
|
0,
|
|
`["model"]`,
|
|
testCase.metaFields,
|
|
"",
|
|
)
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, testCase.expectedError)
|
|
})
|
|
}
|
|
|
|
t.Run("second-only schema may omit usageExamples", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"usage-examples-optional",
|
|
0,
|
|
`["model"]`,
|
|
`usageSchema: {seconds: {type: "number", unit: "second"}},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, plugin.Meta.UsageExamples)
|
|
})
|
|
|
|
t.Run("ValidateV1Meta preserves explicit empty enum presence", func(t *testing.T) {
|
|
meta := Meta{
|
|
APIVersion: 1,
|
|
Key: "invalid-usage-schema",
|
|
Name: "Invalid Usage Schema",
|
|
Version: "1.0.0",
|
|
Author: AuthorMeta{Name: "Test"},
|
|
Models: []string{"model"},
|
|
FetchMode: "per_task",
|
|
UsageSchema: map[string]UsageFieldSchema{
|
|
"duration": {Type: "number", Unit: "second", Enum: []string{}},
|
|
},
|
|
}
|
|
require.ErrorContains(t, ValidateV1Meta(meta), "cannot combine enum with type or unit")
|
|
})
|
|
}
|
|
|
|
func TestUsageUnitLabelContract(t *testing.T) {
|
|
source := routingTestPluginSource("unit-label", 0, `["model"]`, `usageSchema: {
|
|
images: {type: "number", unit: "count", unitLabel: {EN: " image ", zh: "张"}},
|
|
items: {type: "number", unit: "count", unitLabel: "item"},
|
|
legacy: {type: "number", unit: "count"}
|
|
},`, "")
|
|
registry := NewRegistry()
|
|
plugin, err := registry.Register(source, Options{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{"en": "image", "zh": "张"}, plugin.Meta.UsageSchema["images"].UnitLabel)
|
|
assert.Equal(t, LocalizedText{"en": "item"}, plugin.Meta.UsageSchema["items"].UnitLabel)
|
|
assert.Nil(t, plugin.Meta.UsageSchema["legacy"].UnitLabel)
|
|
wire, err := common.Marshal(plugin.Meta.UsageSchema)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"images":{"type":"number","unit":"count","unitLabel":{"en":"image","zh":"张"}},"items":{"type":"number","unit":"count","unitLabel":{"en":"item"}},"legacy":{"type":"number","unit":"count"}}`, string(wire))
|
|
snapshot := registry.Snapshot()
|
|
snapshot.Override[0].UsageSchema["images"].UnitLabel["en"] = "Changed"
|
|
assert.Equal(t, "image", registry.Snapshot().Override[0].UsageSchema["images"].UnitLabel["en"])
|
|
|
|
for _, tc := range []struct{ name, field, message string }{
|
|
{"seconds", `{type:"number",unit:"second",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
|
{"tokens", `{type:"number",unit:"token",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
|
{"credits", `{type:"number",unit:"credit",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
|
{"boolean", `{type:"boolean",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
|
{"enum", `{enum:["a"],unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
|
{"missing English", `{type:"number",unit:"count",unitLabel:{zh:"张"}}`, `must include a non-empty "en"`},
|
|
{"null", `{type:"number",unit:"count",unitLabel:null}`, "must be a string or object"},
|
|
{"empty", `{type:"number",unit:"count",unitLabel:" "}`, "non-empty string"},
|
|
{"invalid locale", `{type:"number",unit:"count",unitLabel:{en:"image",zh_CN:"张"}}`, "invalid locale"},
|
|
{"too long", `{type:"number",unit:"count",unitLabel:"` + strings.Repeat("x", 257) + `"}`, "must not exceed 256 characters"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := CompilePlugin(routingTestPluginSource("invalid-unit-label", 0, `["model"]`, "usageSchema: {value: "+tc.field+"},", ""), Options{})
|
|
require.ErrorContains(t, err, tc.message)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageEnumLabelsContract(t *testing.T) {
|
|
source := routingTestPluginSource("enum-labels", 0, `["model"]`, `usageSchema: {
|
|
mode: {enum: ["none", "video", "other"], enumLabels: {none: "No video", video: {EN: " With video ", zh: "有参考视频"}}}
|
|
},`, "")
|
|
registry := NewRegistry()
|
|
plugin, err := registry.Register(source, Options{})
|
|
require.NoError(t, err)
|
|
labels := plugin.Meta.UsageSchema["mode"].EnumLabels
|
|
assert.Equal(t, LocalizedText{"en": "No video"}, labels["none"])
|
|
assert.Equal(t, LocalizedText{"en": "With video", "zh": "有参考视频"}, labels["video"])
|
|
assert.NotContains(t, labels, "other")
|
|
|
|
wire, err := common.Marshal(plugin.Meta.UsageSchema)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"mode":{"enum":["none","video","other"],"enumLabels":{"none":{"en":"No video"},"video":{"en":"With video","zh":"有参考视频"}}}}`, string(wire))
|
|
snapshot := registry.Snapshot()
|
|
require.Len(t, snapshot.Override, 1)
|
|
snapshot.Override[0].UsageSchema["mode"].EnumLabels["video"]["en"] = "Changed"
|
|
delete(snapshot.Override[0].UsageSchema["mode"].EnumLabels, "none")
|
|
current := registry.Snapshot().Override[0].UsageSchema["mode"].EnumLabels
|
|
assert.Equal(t, "With video", current["video"]["en"])
|
|
assert.Contains(t, current, "none")
|
|
|
|
for _, tc := range []struct{ name, field, message string }{
|
|
{"unknown enum value", `{enum:["a"],enumLabels:{b:"B"}}`, "undeclared enum value"},
|
|
{"numeric field", `{type:"number",unit:"count",enumLabels:{}}`, "enumLabels requires enum"},
|
|
{"boolean field", `{type:"boolean",enumLabels:{true:"Yes"}}`, "enumLabels requires enum"},
|
|
{"missing English", `{enum:["a"],enumLabels:{a:{zh:"中文"}}}`, `must include a non-empty "en"`},
|
|
{"null map", `{enum:["a"],enumLabels:null}`, "enumLabels must be an object"},
|
|
{"null label", `{enum:["a"],enumLabels:{a:null}}`, "must be a string or object"},
|
|
{"empty label", `{enum:["a"],enumLabels:{a:" "}}`, "non-empty string"},
|
|
{"invalid locale", `{enum:["a"],enumLabels:{a:{en:"A",zh_CN:"甲"}}}`, "invalid locale"},
|
|
{"too long", `{enum:["a"],enumLabels:{a:"` + strings.Repeat("x", 257) + `"}}`, "must not exceed 256 characters"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := CompilePlugin(routingTestPluginSource("invalid-labels", 0, `["model"]`, "usageSchema: {mode: "+tc.field+"},", ""), Options{})
|
|
require.ErrorContains(t, err, tc.message)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryValidatesUsageExamples(t *testing.T) {
|
|
tokenSchema := `usageSchema: {tokens: {type: "number", unit: "token"}, mode: {enum: ["std", "pro"]}},`
|
|
validExample := `{label: "std · 1 token", facts: {tokens: 1, mode: "std"}}`
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
metaFields string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "missing schema key",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "std", facts: {tokens: 1}}],`,
|
|
expectedError: `facts missing key "mode"`,
|
|
},
|
|
{
|
|
name: "undeclared facts key",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "std", facts: {tokens: 1, mode: "std", extra: 1}}],`,
|
|
expectedError: `undeclared key "extra"`,
|
|
},
|
|
{
|
|
name: "enum value must be declared",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "ultra", facts: {tokens: 1, mode: "ultra"}}],`,
|
|
expectedError: "enum is not an allowed value",
|
|
},
|
|
{
|
|
name: "token unit requires at least one example",
|
|
metaFields: `usageSchema: {tokens: {type: "number", unit: "token"}},`,
|
|
expectedError: "usageExamples is required when usageSchema declares a token unit",
|
|
},
|
|
{
|
|
name: "cap is 16 examples",
|
|
metaFields: tokenSchema + `usageExamples: [` + strings.Repeat(validExample+",", 16) + validExample + `],`,
|
|
expectedError: "must not exceed 16 entries",
|
|
},
|
|
{
|
|
name: "label must be non-empty",
|
|
metaFields: tokenSchema + `usageExamples: [{label: " ", facts: {tokens: 1, mode: "std"}}],`,
|
|
expectedError: "label is required",
|
|
},
|
|
{
|
|
name: "label is bounded",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "` + strings.Repeat("x", 49) + `", facts: {tokens: 1, mode: "std"}}],`,
|
|
expectedError: "label must not exceed 48 characters",
|
|
},
|
|
{
|
|
name: "usageExamples requires usageSchema",
|
|
metaFields: `usageExamples: [{label: "std", facts: {tokens: 1}}],`,
|
|
expectedError: "usageExamples requires usageSchema",
|
|
},
|
|
{
|
|
name: "token value must stay within the int32 bound",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "overflow", facts: {tokens: 2147483648, mode: "std"}}],`,
|
|
expectedError: "exceeds the host limit",
|
|
},
|
|
{
|
|
name: "second value must stay within the duration bound",
|
|
metaFields: `usageSchema: {seconds: {type: "number", unit: "second"}}, usageExamples: [{label: "too long", facts: {seconds: 3601}}],`,
|
|
expectedError: "exceeds the host limit",
|
|
},
|
|
{
|
|
name: "negative number is rejected",
|
|
metaFields: tokenSchema + `usageExamples: [{label: "neg", facts: {tokens: -1, mode: "std"}}],`,
|
|
expectedError: "finite non-negative number",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
source := routingTestPluginSource(
|
|
"invalid-usage-examples",
|
|
0,
|
|
`["model"]`,
|
|
testCase.metaFields,
|
|
"",
|
|
)
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, testCase.expectedError)
|
|
})
|
|
}
|
|
|
|
t.Run("accepts a complete token example vector", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"valid-usage-examples",
|
|
0,
|
|
`["model"]`,
|
|
tokenSchema+`usageExamples: [`+validExample+`],`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, plugin.Meta.UsageExamples, 1)
|
|
assert.Equal(t, "std · 1 token", plugin.Meta.UsageExamples[0].Label)
|
|
assert.Equal(t, "std", plugin.Meta.UsageExamples[0].Facts["mode"])
|
|
})
|
|
|
|
t.Run("ValidateV1Meta rejects a token schema without examples", func(t *testing.T) {
|
|
meta := Meta{
|
|
APIVersion: 1,
|
|
Key: "token-examples",
|
|
Name: "Token Examples",
|
|
Version: "1.0.0",
|
|
Author: AuthorMeta{Name: "Test"},
|
|
Models: []string{"model"},
|
|
FetchMode: "per_task",
|
|
UsageSchema: map[string]UsageFieldSchema{
|
|
"tokens": {Type: "number", Unit: "token"},
|
|
},
|
|
}
|
|
require.ErrorContains(t, ValidateV1Meta(meta), "usageExamples is required when usageSchema declares a token unit")
|
|
meta.UsageExamples = []UsageExample{{Label: "1 token", Facts: map[string]any{"tokens": 1}}}
|
|
require.NoError(t, ValidateV1Meta(meta))
|
|
})
|
|
}
|
|
|
|
func TestRegistryFindsEffectivePluginByBuiltInChannelType(t *testing.T) {
|
|
registry := NewRegistry()
|
|
_, err := registry.RegisterFactory(`
|
|
export const meta = {apiVersion:1,key:"test",name:"Test",version:"1.0.0",author:{name:"Test"},channelTypes:[1001],models:["test-model"],fetchMode:"per_task"};
|
|
export function buildSubmitRequest(){return {}} export function parseSubmitResponse(){return {}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {}}
|
|
`, Options{})
|
|
require.NoError(t, err)
|
|
plugin, ok := registry.GetByChannelType(1001)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "test", plugin.Meta.Key)
|
|
}
|
|
|
|
func TestTaskPluginRoutingDebugReasonDoesNotExposeRawFailure(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
message string
|
|
expected string
|
|
}{
|
|
{name: "channel type", message: "channelType 80 conflicts with plugin secret", expected: "channel_type_conflict"},
|
|
{name: "endpoint", message: `endpoint https://secret.invalid/?key=hidden conflicts`, expected: "endpoint_conflict"},
|
|
{name: "inner router", message: "inner Gin registration panic: private route", expected: "inner_router_build_failed"},
|
|
{name: "route", message: `route /private/path conflicts`, expected: "route_conflict"},
|
|
{name: "fallback", message: `database https://secret.invalid/?key=hidden`, expected: "generation_rebuild_failed"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
reason := taskPluginRoutingDebugReason(testCase.message)
|
|
assert.Equal(t, testCase.expected, reason)
|
|
assert.NotContains(t, reason, "secret")
|
|
assert.NotContains(t, reason, "hidden")
|
|
})
|
|
}
|
|
}
|
|
|
|
func registerTestPlugin(registry *Registry, version string, factory bool) error {
|
|
source := `
|
|
export const meta = {apiVersion: 1, key: "test", name: "Test", version: "` + version + `", author: {name: "Test"}, models: ["test-model"], fetchMode: "per_task"};
|
|
export function buildSubmitRequest() { return {}; }
|
|
export function parseSubmitResponse() { return {}; }
|
|
export function buildQueryRequest() { return {}; }
|
|
export function parseTaskResult() { return {}; }
|
|
`
|
|
if factory {
|
|
_, err := registry.RegisterFactory(source, Options{})
|
|
return err
|
|
}
|
|
_, err := registry.Register(source, Options{})
|
|
return err
|
|
}
|
|
|
|
func TestRegistryValidatesChannelTypes(t *testing.T) {
|
|
base := Meta{
|
|
APIVersion: 1,
|
|
Key: "compat",
|
|
Name: "Compat",
|
|
Version: "1.0.0",
|
|
Author: AuthorMeta{Name: "Test"},
|
|
Models: []string{"model"},
|
|
FetchMode: "per_task",
|
|
}
|
|
for _, testCase := range []struct {
|
|
name string
|
|
channelTypes []int
|
|
wantErr string
|
|
}{
|
|
{name: "valid list", channelTypes: []int{55, 1}},
|
|
{name: "empty list", channelTypes: nil},
|
|
{name: "zero rejected", channelTypes: []int{0}, wantErr: "positive channel types"},
|
|
{name: "negative rejected", channelTypes: []int{-1}, wantErr: "positive channel types"},
|
|
{name: "task plugin type rejected", channelTypes: []int{constant.ChannelTypeTaskPlugin}, wantErr: "task plugin channel type"},
|
|
{name: "duplicates rejected", channelTypes: []int{1, 1}, wantErr: "must be unique"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
meta := base
|
|
meta.ChannelTypes = testCase.channelTypes
|
|
err := ValidateV1Meta(meta)
|
|
if testCase.wantErr == "" {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
require.ErrorContains(t, err, testCase.wantErr)
|
|
})
|
|
}
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
metaFields string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "removed channelType field",
|
|
metaFields: `channelType: 55,`,
|
|
expectedError: "channelType is no longer supported; declare channelTypes instead",
|
|
},
|
|
{
|
|
name: "removed compatibleChannelTypes field",
|
|
metaFields: `compatibleChannelTypes: [1],`,
|
|
expectedError: "compatibleChannelTypes is no longer supported; declare channelTypes instead",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
source := fmt.Sprintf(`
|
|
export const meta = {
|
|
apiVersion: 1, key: "legacy-field", name: "Legacy", version: "1.0.0",
|
|
author: {name: "Test"}, models: ["model"], fetchMode: "per_task", %s
|
|
};
|
|
export function buildSubmitRequest() { return {}; }
|
|
export function parseSubmitResponse() { return {}; }
|
|
export function buildQueryRequest() { return {}; }
|
|
export function parseTaskResult() { return {}; }
|
|
`, testCase.metaFields)
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, testCase.expectedError)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalizedTextContract(t *testing.T) {
|
|
validMeta := func() Meta {
|
|
return Meta{
|
|
APIVersion: 1,
|
|
Key: "localized-text",
|
|
Name: "Localized Text",
|
|
Version: "1.0.0",
|
|
Author: AuthorMeta{Name: "Test"},
|
|
Models: []string{"model"},
|
|
FetchMode: "per_task",
|
|
}
|
|
}
|
|
|
|
t.Run("bare string meta description normalizes to en", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource("localized-text", 0, `["model"]`, `description: "Video generation via the vendor API",`, ""),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, plugin.Meta.Description)
|
|
})
|
|
|
|
t.Run("bare string usage field description normalizes to en", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"localized-text",
|
|
0,
|
|
`["model"]`,
|
|
`usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{"en": "Generated media duration."}, plugin.Meta.UsageSchema["seconds"].Description)
|
|
})
|
|
|
|
t.Run("map form is accepted when en is present", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"localized-text",
|
|
0,
|
|
`["model"]`,
|
|
`description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
|
|
usageSchema: {seconds: {type: "number", unit: "second", description: {en: "Generated media duration.", "zh-TW": "產生的媒體時長"}}},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, plugin.Meta.Description)
|
|
assert.Equal(t, LocalizedText{"en": "Generated media duration.", "zh-TW": "產生的媒體時長"}, plugin.Meta.UsageSchema["seconds"].Description)
|
|
})
|
|
|
|
t.Run("trim is written back", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource("localized-text", 0, `["model"]`, `description: " Video generation via the vendor API ",`, ""),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, plugin.Meta.Description)
|
|
})
|
|
|
|
t.Run("locale tags are canonicalized to BCP-47 casing", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"localized-text",
|
|
0,
|
|
`["model"]`,
|
|
`description: {EN: "Video generation via the vendor API", "zh-tw": "透過廠商介面產生影片", "zh-hans": "通过厂商接口生成视频"},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, LocalizedText{
|
|
"en": "Video generation via the vendor API",
|
|
"zh-TW": "透過廠商介面產生影片",
|
|
"zh-Hans": "通过厂商接口生成视频",
|
|
}, plugin.Meta.Description)
|
|
})
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
metaFields string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "map missing en",
|
|
metaFields: `description: {zh: "通过厂商接口生成视频"},`,
|
|
expectedError: `must include a non-empty "en" value`,
|
|
},
|
|
{
|
|
name: "en is whitespace",
|
|
metaFields: `description: {en: " ", zh: "通过厂商接口生成视频"},`,
|
|
expectedError: `value for "en" must be a non-empty string`,
|
|
},
|
|
{
|
|
name: "chinese locale key",
|
|
metaFields: `description: {en: "Video generation via the vendor API", "英文": "通过厂商接口生成视频"},`,
|
|
expectedError: `invalid locale "英文"`,
|
|
},
|
|
{
|
|
name: "empty locale key",
|
|
metaFields: `description: {en: "Video generation via the vendor API", "": "through the vendor API"},`,
|
|
expectedError: `invalid locale ""`,
|
|
},
|
|
{
|
|
name: "oversized locale key",
|
|
metaFields: `description: {en: "Video generation via the vendor API", toolonglocalekey123456: "through the vendor API"},`,
|
|
expectedError: `invalid locale "toolonglocalekey123456"`,
|
|
},
|
|
{
|
|
name: "case-variant duplicate locale",
|
|
metaFields: `description: {en: "Video generation via the vendor API", EN: "duplicate"},`,
|
|
expectedError: `duplicate locale "en"`,
|
|
},
|
|
{
|
|
name: "meta description exceeds 512 runes",
|
|
metaFields: `description: "` + strings.Repeat("x", 513) + `",`,
|
|
expectedError: "description must not exceed 512 characters",
|
|
},
|
|
{
|
|
name: "usage field description exceeds 256 runes",
|
|
metaFields: `usageSchema: {seconds: {type: "number", unit: "second", description: "` + strings.Repeat("x", 257) + `"}},`,
|
|
expectedError: "description must not exceed 256 characters",
|
|
},
|
|
{
|
|
name: "control character",
|
|
metaFields: `description: "Video\u0000 generation via the vendor API",`,
|
|
expectedError: "must not contain control characters",
|
|
},
|
|
{
|
|
name: "more than 16 locales",
|
|
metaFields: `description: {en:"a",aa:"a",ab:"a",af:"a",ak:"a",am:"a",an:"a",ar:"a",as:"a",av:"a",ay:"a",az:"a",ba:"a",be:"a",bg:"a",bh:"a",bi:"a"},`,
|
|
expectedError: "must not exceed 16 locales",
|
|
},
|
|
{
|
|
name: "description number",
|
|
metaFields: `description: 1,`,
|
|
expectedError: "description must be a string or object",
|
|
},
|
|
{
|
|
name: "description array",
|
|
metaFields: `description: ["Video generation via the vendor API"],`,
|
|
expectedError: "description must be a string or object",
|
|
},
|
|
{
|
|
name: "unknown meta field is still rejected",
|
|
metaFields: `description: "Video generation via the vendor API", extra: true,`,
|
|
expectedError: `unknown field "extra"`,
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
_, err := CompilePlugin(
|
|
routingTestPluginSource("localized-text", 0, `["model"]`, testCase.metaFields, ""),
|
|
Options{},
|
|
)
|
|
require.ErrorContains(t, err, testCase.expectedError)
|
|
})
|
|
}
|
|
|
|
t.Run("boundary rune lengths and 16 locales are accepted", func(t *testing.T) {
|
|
plugin, err := CompilePlugin(
|
|
routingTestPluginSource(
|
|
"localized-text",
|
|
0,
|
|
`["model"]`,
|
|
`description: {en:"a",aa:"a",ab:"a",af:"a",ak:"a",am:"a",an:"a",ar:"a",as:"a",av:"a",ay:"a",az:"a",ba:"a",be:"a",bg:"a",bh:"a"},
|
|
usageSchema: {seconds: {type: "number", unit: "second", description: "`+strings.Repeat("x", 256)+`"}},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, plugin.Meta.Description, 16)
|
|
assert.Equal(t, 256, len([]rune(plugin.Meta.UsageSchema["seconds"].Description["en"])))
|
|
|
|
plugin, err = CompilePlugin(
|
|
routingTestPluginSource("localized-text-max", 0, `["model"]`, `description: "`+strings.Repeat("x", 512)+`",`, ""),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 512, len([]rune(plugin.Meta.Description["en"])))
|
|
})
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
mutate func(*Meta)
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "ValidateV1Meta map missing en",
|
|
mutate: func(meta *Meta) {
|
|
meta.Description = LocalizedText{"zh": "通过厂商接口生成视频"}
|
|
},
|
|
expectedError: `must include a non-empty "en" value`,
|
|
},
|
|
{
|
|
name: "ValidateV1Meta blank en",
|
|
mutate: func(meta *Meta) {
|
|
meta.Description = LocalizedText{"en": " "}
|
|
},
|
|
expectedError: `value for "en" must be a non-empty string`,
|
|
},
|
|
{
|
|
name: "ValidateV1Meta invalid locale",
|
|
mutate: func(meta *Meta) {
|
|
meta.Description = LocalizedText{"en": "Video generation via the vendor API", "英文": "通过厂商接口生成视频"}
|
|
},
|
|
expectedError: `invalid locale "英文"`,
|
|
},
|
|
{
|
|
name: "ValidateV1Meta usage field too long",
|
|
mutate: func(meta *Meta) {
|
|
meta.UsageSchema = map[string]UsageFieldSchema{
|
|
"seconds": {Type: "number", Unit: "second", Description: LocalizedText{"en": strings.Repeat("x", 257)}},
|
|
}
|
|
},
|
|
expectedError: "description must not exceed 256 characters",
|
|
},
|
|
{
|
|
name: "ValidateV1Meta control character",
|
|
mutate: func(meta *Meta) {
|
|
meta.Description = LocalizedText{"en": "Video\u0000 generation via the vendor API"}
|
|
},
|
|
expectedError: "must not contain control characters",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
meta := validMeta()
|
|
testCase.mutate(&meta)
|
|
require.ErrorContains(t, ValidateV1Meta(meta), testCase.expectedError)
|
|
})
|
|
}
|
|
|
|
t.Run("MarshalJSON of Meta description is an object", func(t *testing.T) {
|
|
meta := validMeta()
|
|
meta.Description = LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}
|
|
encoded, err := common.Marshal(meta)
|
|
require.NoError(t, err)
|
|
var raw map[string]any
|
|
require.NoError(t, common.Unmarshal(encoded, &raw))
|
|
object, ok := raw["description"].(map[string]any)
|
|
require.True(t, ok, "API description must be an object, got %T", raw["description"])
|
|
assert.Equal(t, "Video generation via the vendor API", object["en"])
|
|
assert.Equal(t, "通过厂商接口生成视频", object["zh"])
|
|
})
|
|
|
|
t.Run("UnmarshalJSON accepts string and object", func(t *testing.T) {
|
|
var fromString LocalizedText
|
|
require.NoError(t, common.Unmarshal([]byte(`"Video generation via the vendor API"`), &fromString))
|
|
assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API"}, fromString)
|
|
|
|
var fromObject LocalizedText
|
|
require.NoError(t, common.Unmarshal([]byte(`{"en":"Video generation via the vendor API","zh":"通过厂商接口生成视频"}`), &fromObject))
|
|
assert.Equal(t, LocalizedText{"en": "Video generation via the vendor API", "zh": "通过厂商接口生成视频"}, fromObject)
|
|
|
|
encoded, err := common.Marshal(fromString)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, `{"en":"Video generation via the vendor API"}`, string(encoded))
|
|
})
|
|
|
|
t.Run("cloneMeta deep-copies localized text", func(t *testing.T) {
|
|
registry := NewRegistry()
|
|
_, err := registry.Register(
|
|
routingTestPluginSource(
|
|
"localized-text",
|
|
0,
|
|
`["model"]`,
|
|
`description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
|
|
usageSchema: {seconds: {type: "number", unit: "second", description: {en: "Generated media duration."}}},`,
|
|
"",
|
|
),
|
|
Options{},
|
|
)
|
|
require.NoError(t, err)
|
|
snapshot := registry.Snapshot()
|
|
require.Len(t, snapshot.Override, 1)
|
|
snapshot.Override[0].Description["en"] = "changed"
|
|
snapshot.Override[0].UsageSchema["seconds"].Description["en"] = "changed"
|
|
plugin, ok := registry.Get("localized-text")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "Video generation via the vendor API", plugin.Meta.Description["en"])
|
|
assert.Equal(t, "Generated media duration.", plugin.Meta.UsageSchema["seconds"].Description["en"])
|
|
})
|
|
}
|
|
|
|
func TestRegistryNormalizesBaseURL(t *testing.T) {
|
|
absent, err := CompilePlugin(routingTestPluginSource("base-url-absent", 0, `["model"]`, "", ""), Options{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, absent.Meta.BaseURL)
|
|
|
|
_, err = CompilePlugin(routingTestPluginSource("base-url-type", 0, `["model"]`, "baseUrl: 1,", ""), Options{})
|
|
require.ErrorContains(t, err, "baseUrl must be a string")
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantError string
|
|
}{
|
|
{name: "https accepted", input: "https://api.example.com", want: "https://api.example.com"},
|
|
{name: "http accepted", input: "http://api.example.com", want: "http://api.example.com"},
|
|
{name: "loopback with port accepted", input: "http://127.0.0.1:8000", want: "http://127.0.0.1:8000"},
|
|
{name: "ipv6 literal with port and path accepted", input: "http://[::1]:8000/api/", want: "http://[::1]:8000/api"},
|
|
{name: "trailing slashes stripped", input: "https://api.example.com/v1//", want: "https://api.example.com/v1"},
|
|
{name: "scheme and host lowercased, path preserved", input: "HTTPS://API.Example.COM/V1", want: "https://api.example.com/V1"},
|
|
{name: "userinfo rejected", input: "https://user:pass@api.example.com", wantError: "credentials"},
|
|
{name: "query rejected", input: "https://api.example.com/?x=1", wantError: "query or fragment"},
|
|
{name: "fragment rejected", input: "https://api.example.com/#x", wantError: "query or fragment"},
|
|
{name: "non-http scheme rejected", input: "ftp://api.example.com", wantError: "http or https"},
|
|
{name: "relative value rejected", input: "/v1", wantError: "absolute"},
|
|
{name: "non-ascii host rejected", input: "https://例子.com", wantError: "ASCII"},
|
|
{name: "embedded whitespace rejected", input: "https://api.example.com/a b", wantError: "whitespace"},
|
|
{name: "192 characters rejected", input: "https://api.example.com/" + strings.Repeat("a", 168), wantError: "191"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
loaded, err := CompilePlugin(routingTestPluginSource("base-url", 0, `["model"]`, fmt.Sprintf("baseUrl: %q,", test.input), ""), Options{})
|
|
if test.wantError != "" {
|
|
require.ErrorContains(t, err, test.wantError)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.want, loaded.Meta.BaseURL)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryNormalizesAllowedHosts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hosts string
|
|
want []string
|
|
wantError string
|
|
}{
|
|
{name: "hostname lowercased", hosts: `["Upload.Example.com"]`, want: []string{"upload.example.com"}},
|
|
{name: "host with port accepted", hosts: `["upload.example.com:8443"]`, want: []string{"upload.example.com:8443"}},
|
|
{name: "bracketed ipv6 with port accepted", hosts: `["[::1]:8080"]`, want: []string{"[::1]:8080"}},
|
|
{name: "bracketed ipv6 accepted", hosts: `["[::1]"]`, want: []string{"[::1]"}},
|
|
{name: "bare ipv6 normalized to brackets", hosts: `["::1"]`, want: []string{"[::1]"}},
|
|
{name: "scheme rejected", hosts: `["https://upload.example.com"]`, wantError: "without schemes"},
|
|
{name: "path rejected", hosts: `["upload.example.com/v1"]`, wantError: "without schemes"},
|
|
{name: "credentials rejected", hosts: `["user@upload.example.com"]`, wantError: "without schemes"},
|
|
{name: "port out of range rejected", hosts: `["upload.example.com:99999"]`, wantError: "between 1 and 65535"},
|
|
{name: "non-numeric port rejected", hosts: `["upload.example.com:abc"]`, wantError: "between 1 and 65535"},
|
|
{name: "malformed ipv6 rejected", hosts: `["fe80::1::2"]`, wantError: "bracketed"},
|
|
{name: "duplicate after normalization rejected", hosts: `["a.example.com", "A.example.com"]`, wantError: "unique"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
loaded, err := CompilePlugin(routingTestPluginSource("hosts", 0, `["model"]`, "allowedHosts: "+test.hosts+",", ""), Options{})
|
|
if test.wantError != "" {
|
|
require.ErrorContains(t, err, test.wantError)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.want, loaded.Meta.AllowedHosts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryRejectsImageReferencesInMetaIcon(t *testing.T) {
|
|
for _, icon := range []string{"data:image/png;base64,iVBORw0KGgo=", "https://example.com/icon.png"} {
|
|
_, err := CompilePlugin(routingTestPluginSource("icon-ref", 0, `["model"]`, fmt.Sprintf("icon: %q,", icon), ""), Options{})
|
|
require.ErrorContains(t, err, "icon.svg or icon.png file")
|
|
}
|
|
}
|
|
|
|
func TestDecodeIconDataURI(t *testing.T) {
|
|
svg := func(body string) string {
|
|
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(body))
|
|
}
|
|
pngBytes := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 16)...)
|
|
tests := []struct {
|
|
name string
|
|
icon string
|
|
wantType string
|
|
wantError string
|
|
}{
|
|
{name: "small svg accepted", icon: svg(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><circle id="a" cx="8" cy="8" r="6" fill="#0af"/></defs><use xlink:href="#a"/><style>@media (prefers-color-scheme: dark) { circle { fill: #fff } }</style></svg>`), wantType: "image/svg+xml"},
|
|
{name: "small png accepted", icon: "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), wantType: "image/png"},
|
|
{name: "svg script element rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`), wantError: "script"},
|
|
{name: "svg foreignObject rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div/></foreignObject></svg>`), wantError: "foreignObject"},
|
|
{name: "svg event handler attribute rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>`), wantError: "event handler"},
|
|
{name: "svg external image href rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/a.png"/></svg>`), wantError: "external"},
|
|
{name: "svg javascript href with entity split rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><a href="java script:alert(1)"><text>x</text></a></svg>`), wantError: "external"},
|
|
{name: "svg style import rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(evil.css)</style></svg>`), wantError: "external"},
|
|
{name: "svg doctype rejected", icon: svg(`<!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>`), wantError: "DOCTYPE"},
|
|
{name: "non-svg root rejected", icon: svg(`<html><svg/></html>`), wantError: "root element"},
|
|
{name: "malformed svg rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><g></svg>`), wantError: "well-formed"},
|
|
{name: "non-image media type rejected", icon: "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte("<b>x</b>")), wantError: "image/png"},
|
|
{name: "charset parameter rejected", icon: "data:image/svg+xml;charset=utf-8;base64," + base64.StdEncoding.EncodeToString([]byte("<svg/>")), wantError: "image/png"},
|
|
{name: "missing data prefix rejected", icon: "image/png;base64,iVBORw0KGgo=", wantError: "image/png"},
|
|
{name: "malformed base64 rejected", icon: "data:image/png;base64,@@@", wantError: "base64"},
|
|
{name: "png signature enforced", icon: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("not a png")), wantError: "not a PNG"},
|
|
{name: "oversize payload rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat("<g/>", 140000) + `</svg>`), wantError: "must not exceed"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
mediaType, data, err := DecodeIconDataURI(test.icon)
|
|
if test.wantError != "" {
|
|
require.ErrorContains(t, err, test.wantError)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.wantType, mediaType)
|
|
assert.NotEmpty(t, data)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginDisplayMetadata(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, fields string
|
|
priority int
|
|
website string
|
|
invalid bool
|
|
}{
|
|
{name: "omitted"},
|
|
{name: "zero and empty", fields: `sortPriority: 0, website: "",`},
|
|
{name: "positive", fields: `sortPriority: 20, website: " https://example.com/docs?q=1#intro ",`, priority: 20, website: "https://example.com/docs?q=1#intro"},
|
|
{name: "minimum", fields: `sortPriority: -2147483648,`, priority: -2147483648},
|
|
{name: "maximum", fields: `sortPriority: 2147483647,`, priority: 2147483647},
|
|
{name: "underflow", fields: `sortPriority: -2147483649,`, invalid: true},
|
|
{name: "overflow", fields: `sortPriority: 2147483648,`, invalid: true},
|
|
{name: "fraction", fields: `sortPriority: 1.5,`, invalid: true},
|
|
{name: "numeric string", fields: `sortPriority: "1",`, invalid: true},
|
|
{name: "null priority", fields: `sortPriority: null,`, invalid: true},
|
|
{name: "nonfinite", fields: `sortPriority: Infinity,`, invalid: true},
|
|
{name: "website type", fields: `website: 1,`, invalid: true},
|
|
{name: "http", fields: `website: "http://example.com",`, invalid: true},
|
|
{name: "relative", fields: `website: "/docs",`, invalid: true},
|
|
{name: "missing host", fields: `website: "https:///docs",`, invalid: true},
|
|
{name: "credentials", fields: `website: "https://user:pass@example.com",`, invalid: true},
|
|
{name: "empty credentials", fields: `website: "https://@example.com",`, invalid: true},
|
|
{name: "javascript", fields: `website: "javascript:alert(1)",`, invalid: true},
|
|
{name: "invalid host", fields: `website: "https://-example.com",`, invalid: true},
|
|
{name: "invalid port", fields: `website: "https://example.com:99999",`, invalid: true},
|
|
{name: "whitespace", fields: `website: "https://example.com/a b",`, invalid: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
loaded, err := CompilePlugin(routingTestPluginSource("display", 0, `["model"]`, tc.fields, ""), Options{})
|
|
if tc.invalid {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.priority, loaded.Meta.SortPriority)
|
|
assert.Equal(t, tc.website, loaded.Meta.Website)
|
|
encoded, err := common.Marshal(loaded.Meta)
|
|
require.NoError(t, err)
|
|
var decoded Meta
|
|
require.NoError(t, common.Unmarshal(encoded, &decoded))
|
|
assert.Equal(t, tc.priority, decoded.SortPriority)
|
|
assert.Equal(t, tc.website, decoded.Website)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryUsageProfilesReplaceDefaultsAndIsolateSnapshots(t *testing.T) {
|
|
source := routingTestPluginSource("usage-profiles", 0, `["image", "video", "default", "empty"]`, `
|
|
usageSchema: {seconds: {type: "number", unit: "second"}},
|
|
usageExamples: [{label: "default", facts: {seconds: 1}}],
|
|
usageProfiles: [
|
|
{models: ["image"], schema: {image_count: {type: "number", unit: "count", unitLabel: {en: "image", zh: "张"}}}},
|
|
{models: ["video"], schema: {
|
|
seconds: {type: "number", unit: "second", description: {en: "Duration"}},
|
|
resolution: {enum: ["720P", "1080P"], enumLabels: {"720P": {en: "HD"}}}
|
|
}, examples: [{label: "HD video", facts: {seconds: 5, resolution: "720P"}}]},
|
|
{models: ["empty"], schema: {}}
|
|
],`, "")
|
|
registry := NewRegistry()
|
|
plugin, err := registry.Register(source, Options{})
|
|
require.NoError(t, err)
|
|
|
|
for _, model := range []string{"image", "IMAGE"} {
|
|
schema, examples := plugin.Meta.UsageForModel(model)
|
|
assert.Equal(t, map[string]UsageFieldSchema{"image_count": {Type: "number", Unit: "count", UnitLabel: LocalizedText{"en": "image", "zh": "张"}}}, schema)
|
|
assert.Nil(t, examples, "profiles must not inherit unrelated default examples")
|
|
}
|
|
for _, model := range []string{"default", "unknown", ""} {
|
|
schema, examples := plugin.Meta.UsageForModel(model)
|
|
assert.Equal(t, plugin.Meta.UsageSchema, schema)
|
|
assert.Equal(t, plugin.Meta.UsageExamples, examples)
|
|
}
|
|
schema, examples := plugin.Meta.UsageForModel("empty")
|
|
assert.Empty(t, schema)
|
|
assert.Nil(t, examples)
|
|
|
|
snapshot := registry.Snapshot()
|
|
require.Len(t, snapshot.Override, 1)
|
|
profile := &snapshot.Override[0].UsageProfiles[1]
|
|
snapshot.Override[0].UsageProfiles[0].Schema["image_count"].UnitLabel["en"] = "Changed"
|
|
imageSchema, _ := registry.Snapshot().Override[0].UsageForModel("image")
|
|
assert.Equal(t, "image", imageSchema["image_count"].UnitLabel["en"])
|
|
profile.Models[0] = "changed"
|
|
profile.Schema["seconds"].Description["en"] = "Changed"
|
|
profile.Schema["resolution"].Enum[0] = "changed"
|
|
profile.Schema["resolution"].EnumLabels["720P"]["en"] = "Changed"
|
|
profile.Examples[0].Label = "Changed"
|
|
profile.Examples[0].Facts["seconds"] = 99
|
|
delete(profile.Schema, "seconds")
|
|
|
|
schema, examples = registry.Snapshot().Override[0].UsageForModel("video")
|
|
assert.Equal(t, "Duration", schema["seconds"].Description["en"])
|
|
assert.Equal(t, []string{"720P", "1080P"}, schema["resolution"].Enum)
|
|
assert.Equal(t, "HD", schema["resolution"].EnumLabels["720P"]["en"])
|
|
require.Len(t, examples, 1)
|
|
assert.Equal(t, "HD video", examples[0].Label)
|
|
assert.EqualValues(t, 5, examples[0].Facts["seconds"])
|
|
|
|
legacy, err := CompilePlugin(routingTestPluginSource("legacy-usage", 0, `["image"]`,
|
|
`usageSchema: {seconds: {type: "number", unit: "second"}},`, ""), Options{})
|
|
require.NoError(t, err)
|
|
schema, examples = legacy.Meta.UsageForModel("image")
|
|
assert.Equal(t, legacy.Meta.UsageSchema, schema)
|
|
assert.Nil(t, examples)
|
|
}
|
|
|
|
func TestRegistryRejectsInvalidUsageProfiles(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, profiles, errorText string
|
|
}{
|
|
{"null", `null`, "usageProfiles must be an array"},
|
|
{"object", `{}`, "usageProfiles must be an array"},
|
|
{"invalid entry", `[null]`, "must be an object"},
|
|
{"unknown property", `[{models:["image"], schema:{}, extra:true}]`, "unknown field"},
|
|
{"missing models", `[{schema:{}}]`, "at least one model"},
|
|
{"empty models", `[{models:[], schema:{}}]`, "at least one model"},
|
|
{"unknown model", `[{models:["other"], schema:{}}]`, "not declared"},
|
|
{"noncanonical model", `[{models:[" image"], schema:{}}]`, "canonical names"},
|
|
{"wrong declared spelling", `[{models:["IMAGE"], schema:{}}]`, "not declared"},
|
|
{"duplicate models", `[{models:["image","IMAGE"], schema:{}}]`, "unique case-insensitively"},
|
|
{"overlapping profiles", `[{models:["image"], schema:{}}, {models:["image"], schema:{}}]`, "multiple profiles"},
|
|
{"missing schema", `[{models:["image"]}]`, "must be an object"},
|
|
{"invalid field", `[{models:["image"], schema:{n:{type:"number", unit:"invalid"}}}]`, "unit must be"},
|
|
{"noncanonical field", `[{models:["image"], schema:{" n":{type:"number", unit:"count"}}}]`, "canonical names"},
|
|
{"tokens need examples", `[{models:["image"], schema:{tokens:{type:"number", unit:"token"}}}]`, "usageExamples is required"},
|
|
{"examples use own schema", `[{models:["image"], schema:{n:{type:"number", unit:"count"}}, examples:[{label:"wrong", facts:{seconds:1}}]}]`, "usageProfiles[0]"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
source := routingTestPluginSource("invalid-profile", 0, `["image", "video"]`,
|
|
`usageSchema: {seconds: {type:"number", unit:"second"}}, usageExamples: [{label:"default", facts:{seconds:1}}], usageProfiles: `+tc.profiles+`,`, "")
|
|
_, err := CompilePlugin(source, Options{})
|
|
require.ErrorContains(t, err, tc.errorText)
|
|
})
|
|
}
|
|
|
|
plugin, err := CompilePlugin(routingTestPluginSource("token-profile", 0, `["image"]`, `
|
|
usageProfiles: [{models:["image"], schema:{tokens:{type:"number", unit:"token"}}, examples:[{label:"one token", facts:{tokens:1}}]}],`, ""), Options{})
|
|
require.NoError(t, err)
|
|
schema, examples := plugin.Meta.UsageForModel("image")
|
|
assert.Equal(t, "token", schema["tokens"].Unit)
|
|
require.Len(t, examples, 1)
|
|
assert.EqualValues(t, 1, examples[0].Facts["tokens"])
|
|
plugin.Meta.UsageProfiles[0].Schema = nil
|
|
require.ErrorContains(t, ValidateV1Meta(plugin.Meta), "schema must be an object")
|
|
}
|
|
|
|
func TestSubmitResponseTypesContract(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, metadata, hook string
|
|
valid bool
|
|
}{
|
|
{"legacy JSON", "", "", true},
|
|
{"SSE declared", `submitResponseTypes:["json","sse"],`, `export function parseSubmitEvent(){return {state:null,done:true};}`, true},
|
|
{"host JSON utility", `requiredCapabilities:["json-clone@1"],`, "", true},
|
|
{"SSE delta", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
|
{"SSE delta missing hook", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEvent(){return {state:null,done:true};}`, false},
|
|
{"delta requires SSE", `requiredCapabilities:["submit-sse-delta@1"],`, "", false},
|
|
{"unsupported capability version", `requiredCapabilities:["json-clone@2"],`, "", false},
|
|
{"duplicate capability", `requiredCapabilities:["json-clone@1","json-clone@1"],`, "", false},
|
|
{"null capabilities", `requiredCapabilities:null,`, "", false},
|
|
{"missing hook", `submitResponseTypes:["sse"],`, "", false},
|
|
{"unknown type", `submitResponseTypes:["xml"],`, "", false},
|
|
{"duplicate", `submitResponseTypes:["json","json"],`, "", false},
|
|
{"empty", `submitResponseTypes:[],`, "", false},
|
|
{"null", `submitResponseTypes:null,`, "", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
source := routingTestPluginSource("submit-types", 0, `["model"]`, tc.metadata, "") + tc.hook
|
|
plugin, err := CompilePlugin(source, Options{})
|
|
if tc.valid {
|
|
require.NoError(t, err)
|
|
require.NoError(t, ValidateV1Meta(plugin.Meta))
|
|
} else {
|
|
require.Error(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|