mirror of
https://github.com/alibaba/higress.git
synced 2026-06-26 02:35:02 +08:00
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com> Co-authored-by: woody <yaodiwu618@gmail.com>
343 lines
13 KiB
Go
343 lines
13 KiB
Go
// Copyright (c) 2025 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"testing"
|
|
|
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
|
"github.com/higress-group/wasm-go/pkg/test"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// === helpers =============================================================
|
|
|
|
func mustConfigBytes(t *testing.T, m map[string]interface{}) json.RawMessage {
|
|
t.Helper()
|
|
b, err := json.Marshal(m)
|
|
require.NoError(t, err)
|
|
return b
|
|
}
|
|
|
|
// === Module A — onHttpRequestHeaders edges ==============================
|
|
//
|
|
// onHttpRequestHeaders is 87.5% in baseline. Existing main_test.go drives
|
|
// the bare-suffix match and miss paths but never the query-string strip
|
|
// (main.go:126-128) or the explicit `*` wildcard short-circuit
|
|
// (main.go:132). Both are part of the documented suffix-matching contract.
|
|
|
|
// Path with `?...` query string must be stripped before suffix matching.
|
|
// Without the strip, `/v1/chat/completions?stream=true` would be compared
|
|
// against `/v1/chat/completions` and miss — pin the strip.
|
|
func TestOnHttpRequestHeaders_PathWithQueryStripped(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions?stream=true&debug=1"},
|
|
{":method", "POST"},
|
|
{"content-type", "application/json"},
|
|
})
|
|
require.Equal(t, types.HeaderStopIteration, action)
|
|
})
|
|
}
|
|
|
|
// `*` is the explicit catch-all suffix — must enable for any path. Pins the
|
|
// `suffix == "*"` short-circuit so a future refactor that switched to pure
|
|
// HasSuffix matching (where `"*"` would only match a literal `*` ending)
|
|
// would fail this test.
|
|
func TestOnHttpRequestHeaders_WildcardSuffixEnablesAnyPath(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
|
"modelKey": "model",
|
|
"addProviderHeader": "x-provider",
|
|
"modelToHeader": "x-model",
|
|
"enableOnPathSuffix": []string{"*"},
|
|
})
|
|
host, status := test.NewTestHost(cfg)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/anything/at/all"},
|
|
{":method", "POST"},
|
|
{"content-type", "application/json"},
|
|
})
|
|
require.Equal(t, types.HeaderStopIteration, action)
|
|
})
|
|
}
|
|
|
|
// === Module B — onHttpRequestBody dispatch fall-through =================
|
|
//
|
|
// onHttpRequestBody is 75.0% in baseline. The `else` fall-through at
|
|
// main.go:161-163 — content-type matched the path-suffix gate but is
|
|
// neither application/json nor multipart/form-data — is uncovered. The
|
|
// existing "do not process for unsupported content-type" test only checks
|
|
// the headers phase; nothing exercises the body-side neutral exit.
|
|
func TestOnHttpRequestBody_UnsupportedContentType_PassThrough(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "text/plain"},
|
|
})
|
|
action := host.CallOnHttpRequestBody([]byte("hello"))
|
|
require.Equal(t, types.ActionContinue, action)
|
|
// Neither header was injected.
|
|
_, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.False(t, found)
|
|
_, found = getHeader(host.GetRequestHeaders(), "x-provider")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// === Module C — handleJsonBody edges ====================================
|
|
//
|
|
// handleJsonBody is 83.3%. Two reachable uncovered branches:
|
|
// - main.go:204-207 — invalid JSON → log + ActionContinue (fail-open)
|
|
// - main.go:264-266 — modelValue contains no `/` while addProviderHeader
|
|
// is configured → SplitN returns 1 element, provider rewrite skipped
|
|
|
|
// Malformed JSON body must NOT block the request. The plugin's contract is
|
|
// that a bad payload is the upstream's problem, not this filter's.
|
|
func TestHandleJsonBody_InvalidJson_PassThrough(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "application/json"},
|
|
})
|
|
action := host.CallOnHttpRequestBody([]byte("{not json"))
|
|
require.Equal(t, types.ActionContinue, action)
|
|
|
|
// Crucially: no header injection on bad body — provider rewrite
|
|
// must never fire on a body that wasn't validated.
|
|
_, found := getHeader(host.GetRequestHeaders(), "x-provider")
|
|
require.False(t, found)
|
|
_, found = getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// `model: "plain-model"` (no `/` separator) with addProviderHeader set ⇒
|
|
// modelToHeader still fires (separate concern), but the provider-split
|
|
// block enters and exits via the `else` log branch without rewriting body
|
|
// or setting addProviderHeader. Pins the asymmetry between the two header
|
|
// configs in the no-slash case.
|
|
func TestHandleJsonBody_ModelWithoutSlash_AddProviderConfigured(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "application/json"},
|
|
})
|
|
action := host.CallOnHttpRequestBody([]byte(`{"model":"plain-model","messages":[]}`))
|
|
require.Equal(t, types.ActionContinue, action)
|
|
|
|
// modelToHeader fires unconditionally (line 246-248).
|
|
hv, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.True(t, found)
|
|
require.Equal(t, "plain-model", hv)
|
|
// addProviderHeader path skipped — no x-provider.
|
|
_, found = getHeader(host.GetRequestHeaders(), "x-provider")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// === Module D — handleMultipartBody edges ===============================
|
|
//
|
|
// handleMultipartBody is 73.8% — the largest single gap in main.go. Four
|
|
// reachable uncovered branches addressed below; the writer.CreatePart and
|
|
// io.ReadAll error paths require host-injected i/o failures and are not
|
|
// covered.
|
|
|
|
// content-type is structurally invalid (`boundary` param with no `=value`)
|
|
// ⇒ mime.ParseMediaType returns "invalid media parameter" at main.go:273-277
|
|
// → log + ActionContinue. Distinct from the NoBoundary case below: this
|
|
// fails parsing entirely; that one parses but the param is absent.
|
|
func TestHandleMultipartBody_BadContentType(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "multipart/form-data; boundary"}, // missing `=value`
|
|
})
|
|
action := host.CallOnHttpRequestBody([]byte("ignored"))
|
|
require.Equal(t, types.ActionContinue, action)
|
|
_, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// Body advances past the boundary delimiter into a malformed MIME header
|
|
// (no colon) ⇒ NextPart returns a non-EOF error at main.go:296-299 →
|
|
// log + ActionContinue. Existing test only sees clean parts followed by
|
|
// EOF; the inner-loop error path was unreached.
|
|
func TestHandleMultipartBody_NextPartError(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "multipart/form-data; boundary=xxx"},
|
|
})
|
|
// Boundary delimiter is correct, but the part header that follows
|
|
// has no colon — NextPart fails with "malformed MIME header".
|
|
body := []byte("--xxx\r\nbroken header here\r\n\r\nbody\r\n--xxx--\r\n")
|
|
action := host.CallOnHttpRequestBody(body)
|
|
require.Equal(t, types.ActionContinue, action)
|
|
})
|
|
}
|
|
|
|
// content-type contains the literal `multipart/form-data` (so the dispatch
|
|
// at main.go:159 picks the multipart handler) but no `boundary` parameter
|
|
// ⇒ params["boundary"] miss at main.go:278-282 → log + ActionContinue.
|
|
func TestHandleMultipartBody_NoBoundary(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "multipart/form-data"}, // no `boundary=` param
|
|
})
|
|
action := host.CallOnHttpRequestBody([]byte("ignored"))
|
|
require.Equal(t, types.ActionContinue, action)
|
|
// No header rewrites — handler exited before the parts loop.
|
|
_, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// model field present but value has no `/` ⇒ provider-split block enters
|
|
// the `else` log branch (main.go:343) and falls through to the bottom
|
|
// original-write path; the model field is round-tripped unchanged. modified
|
|
// stays false so the body is not replaced. modelToHeader still fires
|
|
// (mirrors the JSON-side asymmetry above).
|
|
func TestHandleMultipartBody_ModelWithoutSlash(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
host, status := test.NewTestHost(basicConfig)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
modelW, err := writer.CreateFormField("model")
|
|
require.NoError(t, err)
|
|
_, err = modelW.Write([]byte("plain-model"))
|
|
require.NoError(t, err)
|
|
promptW, err := writer.CreateFormField("prompt")
|
|
require.NoError(t, err)
|
|
_, err = promptW.Write([]byte("hi"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "multipart/form-data; boundary=" + writer.Boundary()},
|
|
})
|
|
action := host.CallOnHttpRequestBody(buf.Bytes())
|
|
require.Equal(t, types.ActionContinue, action)
|
|
|
|
// modelToHeader fires before the split logic.
|
|
hv, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.True(t, found)
|
|
require.Equal(t, "plain-model", hv)
|
|
// No provider — split path skipped via the else branch.
|
|
_, found = getHeader(host.GetRequestHeaders(), "x-provider")
|
|
require.False(t, found)
|
|
})
|
|
}
|
|
|
|
// addProviderHeader empty (only modelToHeader configured) ⇒ the entire
|
|
// provider-split block at main.go:316-345 is skipped; the model field is
|
|
// written through the bottom "original part" branch unchanged even with
|
|
// `provider/model` form. Pins the `addProviderHeader == ""` short-circuit.
|
|
func TestHandleMultipartBody_NoAddProviderHeader(t *testing.T) {
|
|
test.RunTest(t, func(t *testing.T) {
|
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
|
"modelKey": "model",
|
|
"modelToHeader": "x-model",
|
|
"enableOnPathSuffix": []string{
|
|
"/v1/chat/completions",
|
|
},
|
|
})
|
|
host, status := test.NewTestHost(cfg)
|
|
defer host.Reset()
|
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
modelW, err := writer.CreateFormField("model")
|
|
require.NoError(t, err)
|
|
_, err = modelW.Write([]byte("openai/gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
|
|
host.CallOnHttpRequestHeaders([][2]string{
|
|
{":authority", "example.com"},
|
|
{":path", "/v1/chat/completions"},
|
|
{":method", "POST"},
|
|
{"content-type", "multipart/form-data; boundary=" + writer.Boundary()},
|
|
})
|
|
action := host.CallOnHttpRequestBody(buf.Bytes())
|
|
require.Equal(t, types.ActionContinue, action)
|
|
|
|
// Header carries the FULL value — no split happened.
|
|
hv, found := getHeader(host.GetRequestHeaders(), "x-model")
|
|
require.True(t, found)
|
|
require.Equal(t, "openai/gpt-4o", hv)
|
|
// No provider header was ever requested.
|
|
_, found = getHeader(host.GetRequestHeaders(), "x-provider")
|
|
require.False(t, found)
|
|
})
|
|
}
|