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.
699 lines
26 KiB
Go
699 lines
26 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/gin-gonic/gin"
|
|
jsoniter "github.com/json-iterator/go"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestEngineCallsESMExportWithInjectedUtils(t *testing.T) {
|
|
t.Parallel()
|
|
logs := make([]string, 0, 1)
|
|
engine, err := Compile(`
|
|
export function sign(ctx) {
|
|
console.log("called", ctx.name);
|
|
return {
|
|
now: utils.unixNow(),
|
|
digest: utils.hmacSHA256(ctx.message, ctx.secret),
|
|
encoded: utils.base64(ctx.message),
|
|
};
|
|
}
|
|
|
|
export const meta = { apiVersion: 1, key: "mock" };
|
|
`, Options{
|
|
Key: "mock", Version: "1.0.0",
|
|
Now: func() time.Time { return time.Unix(1234, 0) },
|
|
Log: func(message string) { logs = append(logs, message) },
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
result, err := engine.Call(context.Background(), "sign", map[string]any{
|
|
"name": "fixture", "message": "hello", "secret": "secret",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{
|
|
"now": int64(1234), "digest": "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b", "encoded": "aGVsbG8=",
|
|
}, result)
|
|
assert.Equal(t, []string{"[plugin:mock@1.0.0] called fixture"}, logs)
|
|
|
|
meta, err := engine.Export(context.Background(), "meta")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"apiVersion": int64(1), "key": "mock"}, meta)
|
|
}
|
|
|
|
func TestEngineJSONCloneProducesIndependentNativeContainers(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export function clone(input) {
|
|
const result = utils.json.clone(input);
|
|
result.items.push({text:"appended"});
|
|
result.items[0].text = "changed";
|
|
return {result, array:Array.isArray(result.items),
|
|
prototype:Object.getPrototypeOf(result) === Object.prototype,
|
|
ownProto:Object.prototype.hasOwnProperty.call(result,"__proto__"),
|
|
available:utils.hasCapability("json-clone@1"), unavailable:utils.hasCapability("json-clone@2")};
|
|
}
|
|
export function invalid(kind) {
|
|
let value;
|
|
switch(kind) {
|
|
case "cycle": value={}; value.self=value; break;
|
|
case "function": value={run:function(){}}; break;
|
|
case "undefined": value={missing:undefined}; break;
|
|
case "nan": value=NaN; break;
|
|
case "bigint": value=1n; break;
|
|
case "date": value=new Date(); break;
|
|
case "sparse": value=[,1]; break;
|
|
case "size": value="<".repeat(200000); break;
|
|
case "nodes": value=new Array(32768).fill(0); break;
|
|
}
|
|
return utils.json.clone(value);
|
|
}`, Options{})
|
|
require.NoError(t, err)
|
|
input := map[string]any{"items": []any{map[string]any{"text": "original"}}, "__proto__": map[string]any{"marker": "data"}, "zero": math.Copysign(0, -1)}
|
|
value, err := engine.Call(t.Context(), "clone", input)
|
|
require.NoError(t, err)
|
|
result := value.(map[string]any)
|
|
assert.Equal(t, true, result["array"])
|
|
assert.Equal(t, true, result["prototype"])
|
|
assert.Equal(t, true, result["ownProto"])
|
|
assert.Equal(t, true, result["available"])
|
|
assert.Equal(t, false, result["unavailable"])
|
|
cloned := result["result"].(map[string]any)
|
|
assert.Equal(t, []any{map[string]any{"text": "changed"}, map[string]any{"text": "appended"}}, cloned["items"])
|
|
assert.Equal(t, map[string]any{"marker": "data"}, cloned["__proto__"])
|
|
assert.True(t, math.Signbit(cloned["zero"].(float64)))
|
|
assert.Equal(t, []any{map[string]any{"text": "original"}}, input["items"])
|
|
for _, kind := range []string{"cycle", "function", "undefined", "nan", "bigint", "date", "sparse", "size", "nodes"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
_, err := engine.Call(t.Context(), "invalid", kind)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJSONStateChangesAndEncodedLimits(t *testing.T) {
|
|
const changes = `[
|
|
[{"op":"set","path":[],"value":{"text":"<","parts":[],"nested":{"value":null}}}],
|
|
[{"op":"appendText","path":["text"],"value":"&\n\"图像😀\u2028"},
|
|
{"op":"append","path":["parts"],"value":{"text":"a","enabled":false}},
|
|
{"op":"set","path":["nested","value"],"value":[1,2]}],
|
|
[{"op":"appendText","path":["parts",0,"text"],"value":"b"},
|
|
{"op":"set","path":["nested","value",0],"value":0},
|
|
{"op":"set","path":["nested","a<b"],"value":{}}]
|
|
]`
|
|
var frames []any
|
|
require.NoError(t, common.UnmarshalJsonStr(changes, &frames))
|
|
const expected = `{"text":"<&\n\"图像😀\u2028","parts":[{"text":"ab","enabled":false}],"nested":{"value":[0,2],"a<b":{}}}`
|
|
var want any
|
|
require.NoError(t, common.UnmarshalJsonStr(expected, &want))
|
|
encoded, err := common.Marshal(want)
|
|
require.NoError(t, err)
|
|
state := NewJSONState(len(encoded))
|
|
for _, frame := range frames {
|
|
require.NoError(t, state.Apply(t.Context(), frame))
|
|
_, err = state.Value()
|
|
require.NoError(t, err, "every intermediate frame must match the codec's actual byte size")
|
|
}
|
|
value, err := state.Value()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, value)
|
|
// One extra byte fails at the update boundary and invalidates publication.
|
|
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{"text"}, "value": "x"}}))
|
|
_, err = state.Value()
|
|
require.Error(t, err)
|
|
|
|
t.Run("replacement releases byte and node budget", func(t *testing.T) {
|
|
state := NewJSONState(16)
|
|
for _, value := range []any{"abcdefghijklmn", map[string]any{}, []any{int64(0), false, nil}, []any(nil), map[string]any(nil)} {
|
|
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
|
|
_, err := state.Value()
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
t.Run("encoded limit includes HTML escaping", func(t *testing.T) {
|
|
state := NewJSONState(13)
|
|
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": "<"}}))
|
|
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{}, "value": "<"}}))
|
|
})
|
|
t.Run("request cancellation", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
state := NewJSONState(64)
|
|
require.ErrorIs(t, state.Apply(ctx, []any{}), context.Canceled)
|
|
})
|
|
t.Run("bounded operation and result complexity", func(t *testing.T) {
|
|
many := make([]any, 257)
|
|
for index := range many {
|
|
many[index] = map[string]any{"op": "set", "path": []any{}, "value": nil}
|
|
}
|
|
require.Error(t, NewJSONState(1024).Apply(t.Context(), many))
|
|
var deep any = nil
|
|
for range 33 {
|
|
deep = []any{deep}
|
|
}
|
|
for _, value := range []any{deep, make([]any, 32768), math.Inf(1)} {
|
|
state := NewJSONState(1 << 20)
|
|
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
|
|
}
|
|
})
|
|
for _, invalid := range []string{
|
|
`[{"op":"remove","path":[],"value":null}]`,
|
|
`[{"op":"set","path":[],"value":null,"extra":true}]`,
|
|
`[{"op":"set","path":["missing","child"],"value":1}]`,
|
|
`[{"op":"set","path":["parts",-1],"value":1}]`,
|
|
`[{"op":"set","path":["parts",0.5],"value":1}]`,
|
|
`[{"op":"set","path":["parts",100000000000000000000],"value":1}]`,
|
|
`[{"op":"appendText","path":["parts"],"value":"x"}]`,
|
|
`[{"op":"append","path":["text"],"value":"x"}]`,
|
|
} {
|
|
t.Run(invalid, func(t *testing.T) {
|
|
state := NewJSONState(1024)
|
|
require.NoError(t, state.Apply(t.Context(), frames[0]))
|
|
var changes any
|
|
require.NoError(t, common.UnmarshalJsonStr(invalid, &changes))
|
|
require.Error(t, state.Apply(t.Context(), changes))
|
|
_, err := state.Value()
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
type bytePreservingJSONCodec struct {
|
|
jsoniter.API
|
|
}
|
|
|
|
func (c bytePreservingJSONCodec) Decode(reader io.Reader, value any) error {
|
|
return c.NewDecoder(reader).Decode(value)
|
|
}
|
|
|
|
func TestJSONStateUTF8Normalization(t *testing.T) {
|
|
const codecEnv = "JSPLUGIN_TEST_BYTE_PRESERVING_JSON"
|
|
if os.Getenv(codecEnv) == "1" {
|
|
// Only this test runs in the child process; the parent's codec and
|
|
// parallel tests are unaffected. Disabling HTML escaping in jsoniter
|
|
// also preserves invalid UTF-8, independently of Sonic's platform support.
|
|
kitutil.SetCodec(bytePreservingJSONCodec{jsoniter.Config{SortMapKeys: true}.Froze()})
|
|
encoded, err := common.Marshal("\xff\xfe")
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte("\"\xff\xfe\""), encoded)
|
|
var decoded any
|
|
require.NoError(t, common.Unmarshal(encoded, &decoded))
|
|
require.Equal(t, "\xff\xfe", decoded)
|
|
} else {
|
|
t.Run("byte preserving codec", func(t *testing.T) {
|
|
executable, err := os.Executable()
|
|
require.NoError(t, err)
|
|
command := exec.CommandContext(t.Context(), executable, "-test.run=^TestJSONStateUTF8Normalization$", "-test.v")
|
|
command.Env = append(os.Environ(), codecEnv+"=1")
|
|
output, err := command.CombinedOutput()
|
|
require.NoError(t, err, "%s", output)
|
|
})
|
|
}
|
|
|
|
const raw = "图像\xff\xfe😀<&\n\"\\\u2028"
|
|
const normalized = "图像\ufffd\ufffd😀<&\n\"\\\u2028"
|
|
for _, tc := range []struct {
|
|
name string
|
|
op string
|
|
path []any
|
|
initial any
|
|
value any
|
|
want any
|
|
}{
|
|
{"set", "set", []any{}, false, raw, normalized},
|
|
{"append", "append", []any{}, []any{float64(0), false}, raw, []any{float64(0), false, normalized}},
|
|
{"appendText", "appendText", []any{"a<b"}, map[string]any{"a<b": "前\xff"}, raw, map[string]any{"a<b": "前\ufffd" + normalized}},
|
|
{"separate invalid bytes", "appendText", []any{}, "\xff", "\xfe", "\ufffd\ufffd"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
encoded, err := common.Marshal(tc.want)
|
|
require.NoError(t, err)
|
|
for _, shortBy := range []int{0, 1} {
|
|
state := NewJSONState(len(encoded) - shortBy)
|
|
err := state.Apply(t.Context(), []any{
|
|
map[string]any{"op": "set", "path": []any{}, "value": tc.initial},
|
|
map[string]any{"op": tc.op, "path": tc.path, "value": tc.value},
|
|
})
|
|
if shortBy == 0 {
|
|
require.NoError(t, err, "normalized JSON must fit exactly")
|
|
value, err := state.Value()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.want, value)
|
|
continue
|
|
}
|
|
require.Error(t, err, "normalized JSON must exceed the limit by one byte")
|
|
value, valueErr := state.Value()
|
|
require.ErrorIs(t, valueErr, err)
|
|
assert.Nil(t, value, "a failed batch must not publish its successful prefix")
|
|
require.ErrorIs(t, state.Apply(t.Context(), []any{
|
|
map[string]any{"op": "set", "path": []any{}, "value": nil},
|
|
}), err, "a later replacement must not revive failed state")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("clone", func(t *testing.T) {
|
|
engine, err := Compile(`export function clone(input) { return utils.json.clone(input); }`, Options{})
|
|
require.NoError(t, err)
|
|
input := map[string]any{"text": raw, "items": []any{float64(0), false, raw}}
|
|
value, err := engine.Call(t.Context(), "clone", input)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"text": normalized, "items": []any{int64(0), false, normalized}}, value)
|
|
assert.Equal(t, raw, input["text"])
|
|
|
|
encoded, err := common.Marshal(normalized)
|
|
require.NoError(t, err)
|
|
padding := strings.Repeat("x", MaxJSONToolBytes-len(encoded))
|
|
value, err = engine.Call(t.Context(), "clone", raw+padding)
|
|
require.NoError(t, err, "normalized clone must fit exactly")
|
|
assert.Equal(t, normalized, strings.TrimSuffix(value.(string), padding))
|
|
value, err = engine.Call(t.Context(), "clone", raw+padding+"x")
|
|
require.Error(t, err, "normalized clone must exceed the limit by one byte")
|
|
assert.Nil(t, value)
|
|
})
|
|
|
|
t.Run("invalid keys and paths remain rejected", func(t *testing.T) {
|
|
for _, change := range []any{
|
|
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\xff\xfe": raw}},
|
|
map[string]any{"op": "set", "path": []any{"\xff\xfe"}, "value": raw},
|
|
} {
|
|
state := NewJSONState(128)
|
|
require.Error(t, state.Apply(t.Context(), []any{
|
|
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\ufffd\ufffd": "original"}},
|
|
change,
|
|
}))
|
|
value, err := state.Value()
|
|
require.Error(t, err)
|
|
assert.Nil(t, value)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEngineConsoleLogUsesDebugLoggerAndRequestContext(t *testing.T) {
|
|
previousDebug := common.DebugEnabled
|
|
common.DebugEnabled = false
|
|
t.Cleanup(func() { common.DebugEnabled = previousDebug })
|
|
|
|
var output bytes.Buffer
|
|
common.LogWriterMu.Lock()
|
|
previousWriter := gin.DefaultErrorWriter
|
|
gin.DefaultErrorWriter = &output
|
|
common.LogWriterMu.Unlock()
|
|
t.Cleanup(func() {
|
|
common.LogWriterMu.Lock()
|
|
gin.DefaultErrorWriter = previousWriter
|
|
common.LogWriterMu.Unlock()
|
|
})
|
|
|
|
plugin, err := CompilePlugin(`
|
|
export const meta = {
|
|
apiVersion: 1,
|
|
key: "console-debug",
|
|
name: "Console debug",
|
|
version: "1.2.3",
|
|
author: {name: "Test"},
|
|
models: ["debug-model"],
|
|
fetchMode: "per_task",
|
|
};
|
|
export function run(label) {
|
|
console.log("checkpoint", label);
|
|
return true;
|
|
}
|
|
export function buildSubmitRequest() { return {url: "https://example.com"}; }
|
|
export function parseSubmitResponse() { return {taskId: "one"}; }
|
|
export function buildQueryRequest() { return {url: "https://example.com"}; }
|
|
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
|
`, Options{})
|
|
require.NoError(t, err)
|
|
engine := plugin.Engine
|
|
|
|
disabledContext := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-disabled")
|
|
_, err = engine.Call(disabledContext, "run", "disabled")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, output.String())
|
|
|
|
common.DebugEnabled = true
|
|
contextA := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-a")
|
|
_, err = engine.Call(contextA, "run", "context-a")
|
|
require.NoError(t, err)
|
|
logA := output.String()
|
|
output.Reset()
|
|
|
|
contextB := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-b")
|
|
_, err = engine.Call(contextB, "run", "context-b")
|
|
require.NoError(t, err)
|
|
logB := output.String()
|
|
output.Reset()
|
|
|
|
_, err = engine.Call(context.Background(), "run", "background")
|
|
require.NoError(t, err)
|
|
logBackground := output.String()
|
|
|
|
assert.Contains(t, logA, "plugin-console-request-a")
|
|
assert.NotContains(t, logA, "plugin-console-request-b")
|
|
assert.Contains(t, logA, "task_plugin subsystem=runtime event=console")
|
|
assert.Contains(t, logA, "[plugin:console-debug@1.2.3] checkpoint context-a")
|
|
assert.NotContains(t, logA, "disabled")
|
|
|
|
assert.Contains(t, logB, "plugin-console-request-b")
|
|
assert.NotContains(t, logB, "plugin-console-request-a")
|
|
assert.Contains(t, logB, "[plugin:console-debug@1.2.3] checkpoint context-b")
|
|
|
|
assert.Contains(t, logBackground, "| SYSTEM |")
|
|
assert.NotContains(t, logBackground, "plugin-console-request-a")
|
|
assert.NotContains(t, logBackground, "plugin-console-request-b")
|
|
assert.Contains(t, logBackground, "[plugin:console-debug@1.2.3] checkpoint background")
|
|
}
|
|
|
|
func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
|
t.Parallel()
|
|
for name, source := range map[string]string{
|
|
"async": `export async function run() {}`,
|
|
"static import": `import value from "dependency"; export function run() { return value; }`,
|
|
"dynamic import": `export function run() { return import("dependency"); }`,
|
|
"top-level await": `const value = await work(); export function run() { return value; }`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := Compile(source, Options{Key: "invalid"})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unsupported plugin syntax")
|
|
})
|
|
}
|
|
|
|
_, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
|
|
t.Parallel()
|
|
// A sourceMappingURL comment must stay inert. Sobek's default loader
|
|
// os.ReadFiles the referenced server path during Compile and turns any
|
|
// load failure into a compile error, so an unresolvable path compiling
|
|
// cleanly proves the loader is disabled.
|
|
engine, err := Compile("export function run() { return 1; }\n//# sourceMappingURL=/nonexistent/leak-probe.map\n", Options{Key: "sourcemap"})
|
|
require.NoError(t, err)
|
|
|
|
value, err := engine.Call(context.Background(), "run")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), value)
|
|
}
|
|
|
|
func TestEngineInterruptsLongRunningHook(t *testing.T) {
|
|
t.Parallel()
|
|
engine, err := Compile(`let calls = 0; export function run(loop) { calls++; if (loop) { while (true) {} } return calls; }`, Options{
|
|
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = engine.Call(context.Background(), "run", true)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "timed out")
|
|
var hookErr *HookError
|
|
assert.False(t, errors.As(err, &hookErr), "timeouts must not be HookError")
|
|
value, err := engine.Call(context.Background(), "run", false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
|
|
}
|
|
|
|
func TestEngineDoesNotReinitializeIdleRuntimeAfterGC(t *testing.T) {
|
|
var initialized atomic.Int64
|
|
engine, err := Compile(`console.log("initialized"); export function run(){return 42;}`, Options{
|
|
Log: func(string) { initialized.Add(1) },
|
|
})
|
|
require.NoError(t, err)
|
|
runtime.GC()
|
|
runtime.GC()
|
|
value, err := engine.Call(t.Context(), "run")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(42), value)
|
|
assert.Equal(t, int64(1), initialized.Load())
|
|
}
|
|
|
|
func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
|
entered := make(chan struct{}, 2)
|
|
release := make(chan struct{})
|
|
defer close(release)
|
|
engine, err := Compile(`export function run(loop) { utils.unixNow(); if (loop) { while (true) {} } return 42; }`, Options{
|
|
Concurrency: 2,
|
|
Now: func() time.Time {
|
|
entered <- struct{}{}
|
|
<-release
|
|
return time.Unix(1, 0)
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
canceled := make(chan error, 1)
|
|
healthy := make(chan error, 1)
|
|
go func() { _, err := engine.Call(ctx, "run", true); canceled <- err }()
|
|
go func() { _, err := engine.Call(t.Context(), "run", false); healthy <- err }()
|
|
<-entered
|
|
<-entered
|
|
_, err = engine.CallPathWithAdmissionTimeout(t.Context(), time.Nanosecond, "run", nil, false)
|
|
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
|
|
cancel()
|
|
release <- struct{}{}
|
|
release <- struct{}{}
|
|
require.ErrorIs(t, <-canceled, context.Canceled)
|
|
require.NoError(t, <-healthy)
|
|
}
|
|
|
|
func TestEnginePreservesMutableExportAndMemberBindings(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export let run = function(){return 1;};
|
|
export const native = {render:function(){return 1;}};
|
|
export function replace(){run=function(){return 2;};native.render=function(){return 3;};}
|
|
`, Options{Concurrency: 1})
|
|
require.NoError(t, err)
|
|
value, err := engine.Call(t.Context(), "run")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), value)
|
|
_, err = engine.Call(t.Context(), "replace")
|
|
require.NoError(t, err)
|
|
value, err = engine.Call(t.Context(), "run")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(2), value)
|
|
value, err = engine.CallMember(t.Context(), "native", "render")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), value)
|
|
}
|
|
|
|
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
source string
|
|
wantMessage string
|
|
wantLen int
|
|
}{
|
|
{
|
|
name: "Error object",
|
|
source: `export function run() { throw new Error("model is required"); }`,
|
|
wantMessage: "model is required",
|
|
},
|
|
{
|
|
name: "raw string throw",
|
|
source: `export function run() { throw "raw string"; }`,
|
|
wantMessage: "raw string",
|
|
},
|
|
{
|
|
name: "truncates to 512 runes",
|
|
source: `export function run() { throw new Error("x".repeat(2000)); }`,
|
|
wantLen: 512,
|
|
wantMessage: strings.Repeat("x", 512),
|
|
},
|
|
{
|
|
name: "scrubs control characters",
|
|
source: "export function run() { throw new Error(\"line1\\nline2\\x1b[31mred\"); }",
|
|
wantMessage: "line1 line2 [31mred",
|
|
},
|
|
{
|
|
name: "throwing message getter falls back without crashing",
|
|
source: `export function run() { throw {get message() { throw {get message() { return "deep"; }}; }}; }`,
|
|
wantMessage: "plugin hook failed",
|
|
},
|
|
}
|
|
for _, testCase := range tests {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
engine, err := Compile(testCase.source, Options{Key: "diag", Version: "1.0.0"})
|
|
require.NoError(t, err)
|
|
|
|
_, err = engine.Call(context.Background(), "run")
|
|
require.Error(t, err)
|
|
|
|
var hookErr *HookError
|
|
require.True(t, errors.As(err, &hookErr))
|
|
assert.Equal(t, "run", hookErr.Hook)
|
|
assert.Equal(t, testCase.wantMessage, hookErr.Message)
|
|
if testCase.wantLen > 0 {
|
|
assert.Equal(t, testCase.wantLen, utf8.RuneCountInString(hookErr.Message))
|
|
}
|
|
assert.Contains(t, hookErr.Error(), "plugin diag@1.0.0")
|
|
assert.NotContains(t, hookErr.Message, "Error:")
|
|
assert.NotContains(t, hookErr.Message, "plugin diag@")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngineReportsProtocolAdmissionTimeoutSeparately(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export const protocols = {
|
|
responses: {renderEvents: function() { return {events: [], done: false}; }},
|
|
};
|
|
`, Options{Key: "admission", Version: "1", Concurrency: 1})
|
|
require.NoError(t, err)
|
|
|
|
engine.semaphore <- struct{}{}
|
|
_, err = engine.CallPathWithAdmissionTimeout(
|
|
context.Background(),
|
|
time.Nanosecond,
|
|
"protocols",
|
|
[]string{"responses", "renderEvents"},
|
|
)
|
|
<-engine.semaphore
|
|
|
|
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
|
|
result, err := engine.CallPathWithAdmissionTimeout(
|
|
context.Background(),
|
|
time.Second,
|
|
"protocols",
|
|
[]string{"responses", "renderEvents"},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"events": []any{}, "done": false}, result)
|
|
}
|
|
|
|
func TestEngineExportInterruptsLongRunningGetter(t *testing.T) {
|
|
t.Parallel()
|
|
engine, err := Compile(`
|
|
export const meta = {
|
|
apiVersion: 1,
|
|
get name() { while (true) {} },
|
|
};
|
|
`, Options{Key: "meta-loop", Version: "1.0.0", Timeout: 20 * time.Millisecond})
|
|
require.NoError(t, err)
|
|
|
|
_, err = engine.Export(context.Background(), "meta")
|
|
require.ErrorContains(t, err, "export meta interrupted")
|
|
}
|
|
|
|
func TestEngineExportReturnsThrownGetterError(t *testing.T) {
|
|
t.Parallel()
|
|
engine, err := Compile(`
|
|
export const meta = {
|
|
apiVersion: 1,
|
|
get name() { throw new Error("getter failed"); },
|
|
};
|
|
`, Options{Key: "meta-throw", Version: "1.0.0"})
|
|
require.NoError(t, err)
|
|
|
|
_, err = engine.Export(context.Background(), "meta")
|
|
require.ErrorContains(t, err, "export meta failed")
|
|
assert.Contains(t, err.Error(), "getter failed")
|
|
}
|
|
|
|
func TestEngineNestedHooksRequireOwnProperties(t *testing.T) {
|
|
t.Parallel()
|
|
engine, err := Compile(`
|
|
const inheritedRenderers = {
|
|
inherited: function(value) { return value; },
|
|
constructor: function(value) { return value; },
|
|
toString: function(value) { return value; },
|
|
["__proto__"]: function(value) { return value; },
|
|
};
|
|
export const renderers = Object.create(inheritedRenderers);
|
|
renderers.own = function(value) { return {id: value.id}; };
|
|
|
|
const inheritedProtocol = {
|
|
renderFinal: function(value) { return value; },
|
|
};
|
|
export const protocols = {
|
|
responses: Object.create(inheritedProtocol),
|
|
};
|
|
`, Options{Key: "own-hooks", Version: "1.0.0"})
|
|
require.NoError(t, err)
|
|
|
|
found, err := engine.HasCallablePath(context.Background(), "renderers", "own")
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
result, err := engine.CallMember(context.Background(), "renderers", "own", map[string]any{"id": "task-1"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"id": "task-1"}, result)
|
|
|
|
for _, member := range []string{"inherited", "constructor", "toString", "__proto__"} {
|
|
t.Run(member, func(t *testing.T) {
|
|
found, err := engine.HasCallablePath(context.Background(), "renderers", member)
|
|
require.NoError(t, err)
|
|
assert.False(t, found)
|
|
|
|
_, err = engine.CallMember(context.Background(), "renderers", member)
|
|
require.ErrorContains(t, err, "not found")
|
|
})
|
|
}
|
|
|
|
found, err = engine.HasCallablePath(context.Background(), "protocols", "responses", "renderFinal")
|
|
require.NoError(t, err)
|
|
assert.False(t, found)
|
|
_, err = engine.CallPath(context.Background(), "protocols", []string{"responses", "renderFinal"})
|
|
require.ErrorContains(t, err, "not found")
|
|
}
|
|
|
|
func TestCompileInterruptsLongRunningInitialization(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := Compile(`while (true) {}; export function run() {}`, Options{
|
|
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "initialization timed out")
|
|
}
|
|
|
|
func TestValidateRequestURL(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
requestURL string
|
|
baseURL string
|
|
allowedHosts []string
|
|
wantError string
|
|
}{
|
|
{name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
|
|
{name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
|
|
{name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
|
|
{name: "approved host with port", requestURL: "http://192.168.1.10:8080/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}},
|
|
{name: "approved host with explicit default port", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com:443"}},
|
|
{name: "approved host port mismatch", requestURL: "http://192.168.1.10:9000/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}, wantError: "not allowed"},
|
|
{name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
|
|
{name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
|
|
{name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := ValidateRequestURL(test.requestURL, test.baseURL, test.allowedHosts)
|
|
if test.wantError == "" {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
require.Error(t, err)
|
|
assert.True(t, strings.Contains(err.Error(), test.wantError), err.Error())
|
|
})
|
|
}
|
|
}
|