1011 lines
38 KiB
Go
1011 lines
38 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
"unsafe"
|
|
"weak"
|
|
|
|
"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 TestEngineRawJSONArgumentsMatchDecodedArguments(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export function describe(...values) {
|
|
const seen = [];
|
|
const stack = values.reverse().map((v) => ({v}));
|
|
while (stack.length > 0) {
|
|
const {v, key, close} = stack.pop();
|
|
if (close) {
|
|
seen.push(close);
|
|
continue;
|
|
}
|
|
if (key !== undefined) seen.push(key);
|
|
if (v === null || typeof v !== "object") {
|
|
seen.push(typeof v + ":" + (Object.is(v, -0) ? "-0" : JSON.stringify(v)));
|
|
continue;
|
|
}
|
|
const array = Array.isArray(v);
|
|
seen.push(array ? "[" : Object.getPrototypeOf(v) === Object.prototype ? "{" : "{?");
|
|
stack.push({close: array ? "]" : "}"});
|
|
const keys = Object.keys(v);
|
|
for (let i = keys.length - 1; i >= 0; i--) {
|
|
stack.push({v: v[keys[i]], key: array ? undefined : JSON.stringify(keys[i])});
|
|
}
|
|
}
|
|
return seen.join(" ");
|
|
}`, Options{})
|
|
require.NoError(t, err)
|
|
// Hosts pass text written by common.Marshal, where both paths agree on
|
|
// key order, numbers and strings.
|
|
values := map[string]any{
|
|
"object": map[string]any{
|
|
"__proto__": map[string]any{"polluted": true},
|
|
"10": "ten", "2": "two", "a": []any{}, "b": map[string]any{},
|
|
"numbers": []any{0, math.Copysign(0, -1), 1e21, int64(123456789012345678), 1.5e-7, -12, math.MaxInt64, math.SmallestNonzeroFloat64},
|
|
"strings": []any{"", "<>&
", "é中😀", "\u0000\t\"\\", "a\xffb"},
|
|
"nested": map[string]any{"z": nil, "y": []any{true, false, map[string]any{"x": []any{[]any{1}}}}},
|
|
},
|
|
"array": []any{map[string]any{"id": "x", "b": 1, "a": 2}, nil, "s"},
|
|
"string": "plain",
|
|
"number": 42.5,
|
|
"null": nil,
|
|
"deep": json.RawMessage(strings.Repeat("[", 600) + strings.Repeat("]", 600)),
|
|
}
|
|
for name, value := range values {
|
|
t.Run(name, func(t *testing.T) {
|
|
text, err := common.Marshal(value)
|
|
require.NoError(t, err)
|
|
var decoded any
|
|
require.NoError(t, common.Unmarshal(text, &decoded))
|
|
want, err := engine.Call(t.Context(), "describe", decoded)
|
|
require.NoError(t, err)
|
|
got, err := engine.Call(t.Context(), "describe", RawJSON(text))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
nested, err := engine.Call(t.Context(), "describe", map[string]any{"value": RawJSON(text)})
|
|
require.NoError(t, err)
|
|
wantNested, err := engine.Call(t.Context(), "describe", map[string]any{"value": decoded})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, wantNested, nested)
|
|
})
|
|
}
|
|
|
|
_, err = engine.Call(t.Context(), "describe", "first", RawJSON(`{"a":`))
|
|
require.EqualError(t, err, "plugin @ hook describe argument 2: unexpected end of JSON input")
|
|
}
|
|
|
|
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; }`,
|
|
"export from": `export { value as run } from "dependency";`,
|
|
"export star": `export * from "dependency"; export function run() {}`,
|
|
} {
|
|
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)
|
|
|
|
for name, body := range map[string]string{
|
|
"eval": `return eval("import('dependency')");`,
|
|
"Function constructor": `return Function("return async function () {}")();`,
|
|
} {
|
|
engine, err := Compile(`export function run() { `+body+` }`, Options{Key: "dynamic"})
|
|
require.NoError(t, err, name)
|
|
_, err = engine.Call(context.Background(), "run")
|
|
assert.ErrorContains(t, err, "EvalError", name)
|
|
}
|
|
_, err = Compile(`eval("1"); export function run() {}`, Options{Key: "dynamic"})
|
|
assert.ErrorContains(t, err, "EvalError")
|
|
}
|
|
|
|
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 TestEngineIdleRuntimeReleasesLastCallArguments(t *testing.T) {
|
|
engine, err := Compile(`export function run(body){return body.image.length;}`, Options{Concurrency: 1})
|
|
require.NoError(t, err)
|
|
image := strings.Repeat("A", 1<<20)
|
|
released := weak.Make(unsafe.StringData(image))
|
|
value, err := engine.Call(t.Context(), "run", map[string]any{"image": image})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1<<20), value)
|
|
runtime.GC()
|
|
runtime.GC()
|
|
assert.Nil(t, released.Value(), "an idle pooled runtime must not keep the last request body alive")
|
|
value, err = engine.Call(t.Context(), "run", map[string]any{"image": "AB"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(2), value)
|
|
}
|
|
|
|
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 TestEngineFetchContract(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export function run(request) { return utils.fetch(request); }
|
|
export function fetchBytes(url) { return Array.from(new Uint8Array(utils.fetch({url: url, responseType: "bytes"}).body)); }
|
|
export function fetchFiveTimes() {
|
|
const failures = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
try { utils.fetch({url: "https://api.example/json"}); } catch (e) { failures.push(e.message); }
|
|
}
|
|
return failures;
|
|
}`, Options{Key: "fetch"})
|
|
require.NoError(t, err)
|
|
var requests []FetchRequest
|
|
ctx := WithFetcher(t.Context(), func(_ context.Context, request FetchRequest) (FetchResponse, error) {
|
|
requests = append(requests, request)
|
|
switch request.URL {
|
|
case "https://api.example/json":
|
|
return FetchResponse{Status: http.StatusOK, Headers: map[string]string{"Content-Type": "application/json"}, Body: []byte(`{"file":{"id":7}}`)}, nil
|
|
case "https://api.example/text":
|
|
return FetchResponse{Status: http.StatusNotFound, Body: []byte("not found")}, nil
|
|
case "https://api.example/bytes":
|
|
return FetchResponse{Status: http.StatusPartialContent, Body: []byte{0, 0x80, 0xff, '{'}}, nil
|
|
case "https://api.example/large":
|
|
return FetchResponse{Status: http.StatusOK, Body: bytes.Repeat([]byte("a"), MaxFetchBodyBytes+1)}, nil
|
|
case "https://api.example/down?token=secret":
|
|
return FetchResponse{}, &url.Error{Op: "Get", URL: request.URL, Err: errors.New("connection refused")}
|
|
}
|
|
return FetchResponse{Status: http.StatusOK, Body: []byte(`{"ignored":true}`)}, nil
|
|
})
|
|
|
|
_, err = engine.Call(t.Context(), "run", map[string]any{"url": "https://api.example/json"})
|
|
assert.ErrorContains(t, err, "utils.fetch is not available in run")
|
|
|
|
value, err := engine.Call(ctx, "run", map[string]any{"url": "https://api.example/json", "headers": map[string]any{"Authorization": "Bearer key"}})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{
|
|
"status": int64(http.StatusOK),
|
|
"headers": map[string]any{"Content-Type": "application/json"},
|
|
"body": map[string]any{"file": map[string]any{"id": int64(7)}},
|
|
}, value)
|
|
assert.Equal(t, []FetchRequest{{Hook: "run", URL: "https://api.example/json", Method: http.MethodGet, Headers: map[string]string{"Authorization": "Bearer key"}}}, requests)
|
|
|
|
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/text"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"status": int64(http.StatusNotFound), "headers": map[string]any{}, "body": "not found"}, value)
|
|
|
|
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/head", "method": "head"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "", value.(map[string]any)["body"])
|
|
assert.Equal(t, http.MethodHead, requests[len(requests)-1].Method)
|
|
|
|
// Bytes that are neither JSON nor UTF-8 reach the plugin unchanged.
|
|
value, err = engine.Call(ctx, "fetchBytes", "https://api.example/bytes")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []any{int64(0), int64(0x80), int64(0xff), int64('{')}, value)
|
|
|
|
sent := len(requests)
|
|
for name, request := range map[string]map[string]any{
|
|
"unsupported member \"body\"": {"url": "https://api.example/json", "body": "x"},
|
|
"only GET and HEAD": {"url": "https://api.example/json", "method": "POST"},
|
|
`responseType must be "json" or "bytes"`: {"url": "https://api.example/json", "responseType": "text"},
|
|
"request header \"Host\" is not": {"url": "https://api.example/json?token=secret", "headers": map[string]any{"Host": "other.example"}},
|
|
"absolute http(s) URL": {"url": "/v1/files/retrieve"},
|
|
"exceeds 65536 bytes": {"url": "https://api.example/" + strings.Repeat("a", 64<<10)},
|
|
} {
|
|
_, err = engine.Call(ctx, "run", request)
|
|
require.Error(t, err, name)
|
|
assert.Contains(t, err.Error(), name)
|
|
assert.NotContains(t, err.Error(), "secret")
|
|
}
|
|
assert.Len(t, requests, sent, "rejected requests must not reach the fetcher")
|
|
|
|
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/large"})
|
|
assert.ErrorContains(t, err, "utils.fetch response from api.example exceeds 1048576 bytes")
|
|
|
|
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/down?token=secret"})
|
|
var hookErr *HookError
|
|
require.ErrorAs(t, err, &hookErr)
|
|
assert.Equal(t, "utils.fetch to api.example failed: connection refused", hookErr.Message)
|
|
|
|
value, err = engine.Call(ctx, "fetchFiveTimes")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []any{"utils.fetch allows at most 4 requests per hook call"}, value)
|
|
}
|
|
|
|
func TestEngineFetchLendsItsExecutionSlot(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export function fetchOnce() { return utils.fetch({url: "https://api.example/slow"}).body; }
|
|
export function plain() { return 42; }`, Options{Key: "fetch-slot", Concurrency: 1})
|
|
require.NoError(t, err)
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
|
close(entered)
|
|
<-release
|
|
return FetchResponse{Status: http.StatusOK, Body: []byte(`"done"`)}, nil
|
|
})
|
|
fetched := make(chan any, 1)
|
|
go func() {
|
|
value, err := engine.Call(ctx, "fetchOnce")
|
|
assert.NoError(t, err)
|
|
fetched <- value
|
|
}()
|
|
<-entered
|
|
|
|
// The only slot is lent while the fetch waits; without that this call
|
|
// would wait for its deadline.
|
|
waitContext, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
value, err := engine.Call(waitContext, "plain")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(42), value)
|
|
|
|
close(release)
|
|
assert.Equal(t, "done", <-fetched)
|
|
}
|
|
|
|
func TestEngineFetchWaitDoesNotCountTowardCallTimeout(t *testing.T) {
|
|
engine, err := Compile(`export function run() { return utils.fetch({url: "https://api.example/slow"}).status; }`, Options{
|
|
Key: "fetch-clock", Timeout: 20 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
|
close(entered)
|
|
<-release
|
|
return FetchResponse{Status: http.StatusNoContent}, nil
|
|
})
|
|
result := make(chan error, 1)
|
|
var value any
|
|
go func() {
|
|
var err error
|
|
value, err = engine.Call(ctx, "run")
|
|
result <- err
|
|
}()
|
|
<-entered
|
|
// Five times the JavaScript timeout: the clock is stopped while waiting.
|
|
time.Sleep(100 * time.Millisecond)
|
|
close(release)
|
|
require.NoError(t, <-result)
|
|
assert.Equal(t, int64(http.StatusNoContent), value)
|
|
}
|
|
|
|
func TestEngineInterruptsAFetchThatCannotResume(t *testing.T) {
|
|
engine, err := Compile(`
|
|
let calls = 0;
|
|
export function run(fetch) {
|
|
calls++;
|
|
if (!fetch) return calls;
|
|
try { utils.fetch({url: "https://api.example/slow"}); } catch (e) { return "caught: " + e.message; }
|
|
return "resumed";
|
|
}`, Options{Key: "fetch-resume", Concurrency: 1})
|
|
require.NoError(t, err)
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
ctx = WithFetcher(ctx, func(context.Context, FetchRequest) (FetchResponse, error) {
|
|
close(entered)
|
|
<-release
|
|
return FetchResponse{Status: http.StatusOK}, nil
|
|
})
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.Call(ctx, "run", true)
|
|
result <- err
|
|
}()
|
|
<-entered
|
|
// Another call takes the lent slot and the caller leaves before the fetch
|
|
// returns, so the slot cannot be taken back.
|
|
engine.semaphore <- struct{}{}
|
|
cancel()
|
|
close(release)
|
|
err = <-result
|
|
<-engine.semaphore
|
|
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
var hookErr *HookError
|
|
assert.False(t, errors.As(err, &hookErr), "JavaScript must not catch a failed resume")
|
|
value, err := engine.Call(t.Context(), "run", false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
|
|
assert.Empty(t, engine.semaphore)
|
|
assert.Empty(t, engine.parked)
|
|
}
|
|
|
|
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 TestEngineHasExportUsesBindingsAsLoaded(t *testing.T) {
|
|
engine, err := Compile(`
|
|
export let later;
|
|
export const unset = undefined;
|
|
export function run(){return 1;}
|
|
export function assign(){later=function(){return 2;};}
|
|
`, Options{Concurrency: 1})
|
|
require.NoError(t, err)
|
|
|
|
engine.semaphore <- struct{}{}
|
|
assert.True(t, engine.HasExport("run"))
|
|
assert.False(t, engine.HasExport("later"))
|
|
assert.False(t, engine.HasExport("unset"))
|
|
assert.False(t, engine.HasExport("missing"))
|
|
<-engine.semaphore
|
|
|
|
_, err = engine.Call(t.Context(), "assign")
|
|
require.NoError(t, err)
|
|
assert.False(t, engine.HasExport("later"))
|
|
value, err := engine.Call(t.Context(), "later")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(2), 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())
|
|
})
|
|
}
|
|
}
|