mirror of
https://github.com/alibaba/higress.git
synced 2026-06-26 02:35:02 +08:00
test(wasm-plugins): lift unit-test coverage to ≥90% across 9 plugins (#3879)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com> Co-authored-by: woody <yaodiwu618@gmail.com>
This commit is contained in:
130
plugins/wasm-go/extensions/ai-statistics/main_extra_test.go
Normal file
130
plugins/wasm-go/extensions/ai-statistics/main_extra_test.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// 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 (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||||
|
"github.com/higress-group/wasm-go/pkg/test"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Module A — parseConfig validation & wildcard short-circuits =========
|
||||||
|
//
|
||||||
|
// parseConfig is 88.7% in baseline. Four reachable uncovered branches
|
||||||
|
// pinned below; together they exercise the full validation contract for
|
||||||
|
// user-supplied attributes lists and the `*` wildcards on the two enable
|
||||||
|
// gates. Without these tests:
|
||||||
|
// - a malformed attribute object would silently survive ParseConfig
|
||||||
|
// - an unknown rule string ("bogus") would propagate downstream and only
|
||||||
|
// fail at attribute application time
|
||||||
|
// - the `*` wildcard would behave as if it were a literal path suffix
|
||||||
|
//
|
||||||
|
// All four are driven through ParseConfig+NewTestHost rather than calling
|
||||||
|
// parseConfig directly so the wasm logger is initialized.
|
||||||
|
|
||||||
|
// `attributes: [42]` ⇒ gjson Array yields one element whose .Raw is "42";
|
||||||
|
// json.Unmarshal of that into Attribute struct returns
|
||||||
|
// "json: cannot unmarshal number into Go value of type main.Attribute" →
|
||||||
|
// parseConfig returns the error → host start fails. Pins main.go:581-584.
|
||||||
|
func TestParseConfig_AttributeNotObject_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost([]byte(`{
|
||||||
|
"attributes": [42]
|
||||||
|
}`))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `rule` value not in the allowed enum ⇒ parseConfig returns
|
||||||
|
// "value of rule must be one of [nil, first, replace, append]" →
|
||||||
|
// host start fails. Pins main.go:585-587. The existing main_test.go
|
||||||
|
// fixtures only ever use the four legal rule values.
|
||||||
|
func TestParseConfig_InvalidRule_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost([]byte(`{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"key": "x",
|
||||||
|
"value_source": "fixed_value",
|
||||||
|
"value": "y",
|
||||||
|
"rule": "bogus"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `enable_path_suffixes: ["*"]` ⇒ wildcard short-circuit clears the list
|
||||||
|
// and breaks out of the loop, leaving an empty enabledSuffixes list which
|
||||||
|
// isPathEnabled treats as "all paths enabled". Distinct from the existing
|
||||||
|
// "default path suffixes" tests where the suffixes are a literal list.
|
||||||
|
// Pins main.go:635-638.
|
||||||
|
func TestParseConfig_PathSuffixWildcard_EnablesAllPaths(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost([]byte(`{
|
||||||
|
"enable_path_suffixes": ["*"]
|
||||||
|
}`))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
c := conf.(*AIStatisticsConfig)
|
||||||
|
// Wildcard must collapse to empty slice — isPathEnabled then
|
||||||
|
// returns true for any path (per main.go:512-514).
|
||||||
|
require.Len(t, c.enablePathSuffixes, 0)
|
||||||
|
require.True(t, isPathEnabled("/anything", c.enablePathSuffixes))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same wildcard contract on the content-type gate. Pins main.go:650-653.
|
||||||
|
func TestParseConfig_ContentTypeWildcard_EnablesAllContentTypes(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost([]byte(`{
|
||||||
|
"enable_content_types": ["*"]
|
||||||
|
}`))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
c := conf.(*AIStatisticsConfig)
|
||||||
|
require.Len(t, c.enableContentTypes, 0)
|
||||||
|
require.True(t, isContentTypeEnabled("text/anything", c.enableContentTypes))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — convertToUInt unsupported types =========================
|
||||||
|
//
|
||||||
|
// convertToUInt is 100% per existing tests, BUT the existing
|
||||||
|
// TestConvertToUInt only exercises the documented numeric types and one
|
||||||
|
// `"10"` string for the default branch. Pin two more default-branch
|
||||||
|
// shapes that are realistic in production (nil from a missing user
|
||||||
|
// attribute, slice from a malformed type assertion) so a future "support
|
||||||
|
// strings via Atoi" change can't sneak past unnoticed.
|
||||||
|
func TestConvertToUInt_NilAndSlice_FallToDefault(t *testing.T) {
|
||||||
|
v, ok := convertToUInt(nil)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Equal(t, uint64(0), v)
|
||||||
|
|
||||||
|
v, ok = convertToUInt([]int{1, 2, 3})
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Equal(t, uint64(0), v)
|
||||||
|
}
|
||||||
288
plugins/wasm-go/extensions/basic-auth/main_extra_test.go
Normal file
288
plugins/wasm-go/extensions/basic-auth/main_extra_test.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"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 =============================================================
|
||||||
|
|
||||||
|
// mustConfig marshals m to JSON and fails the test on error.
|
||||||
|
func mustConfig(t *testing.T, m map[string]interface{}) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuthHeader returns an Authorization header pair carrying base64-encoded
|
||||||
|
// "user:pwd". Cuts boilerplate across module B/C credential cases.
|
||||||
|
func basicAuthHeader(user, pwd string) [2]string {
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(user + ":" + pwd))
|
||||||
|
return [2]string{"authorization", "Basic " + enc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleConfig produces a config with two consumers and a single _rules_ entry
|
||||||
|
// scoped to "route-a"; pass globalAuth as nil to leave the field unset.
|
||||||
|
func ruleConfig(t *testing.T, globalAuth interface{}, allow []string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credential": "admin:123456"},
|
||||||
|
{"name": "consumer2", "credential": "guest:abc"},
|
||||||
|
},
|
||||||
|
"_rules_": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"_match_route_": []string{"route-a"},
|
||||||
|
"allow": allow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if globalAuth != nil {
|
||||||
|
cfg["global_auth"] = globalAuth
|
||||||
|
}
|
||||||
|
return mustConfig(t, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module A — contains helper =========================================
|
||||||
|
|
||||||
|
// contains is a tiny package-private helper but ships at 0% in the baseline
|
||||||
|
// coverage; the wasm entry path that calls it is exercised only through the
|
||||||
|
// allow-list branches of onHttpRequestHeaders, which themselves are not
|
||||||
|
// covered (Module B fixes that). A direct unit test pins behavior in case
|
||||||
|
// the wasm path regresses or the helper is reused elsewhere.
|
||||||
|
|
||||||
|
func TestContains_Hit(t *testing.T) {
|
||||||
|
require.True(t, contains([]string{"a", "b", "c"}, "b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_Miss(t *testing.T) {
|
||||||
|
require.False(t, contains([]string{"a", "b", "c"}, "z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_EmptySlice(t *testing.T) {
|
||||||
|
require.False(t, contains([]string{}, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_NilSlice(t *testing.T) {
|
||||||
|
require.False(t, contains(nil, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — denied-unauthorized-consumer paths ======================
|
||||||
|
//
|
||||||
|
// deniedUnauthorizedConsumer is 0% in baseline coverage. It is reached when
|
||||||
|
// credentials decode and authenticate, but the consumer's name is missing
|
||||||
|
// from a route-scoped allow list. Documented at main.go:310-314 (RFC 7617
|
||||||
|
// §2 "realm" + Higress 403 contract). Both global_auth=true and
|
||||||
|
// global_auth=false branches converge on the same denial helper.
|
||||||
|
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_ConsumerNotAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
// consumer2 (guest:abc) authenticates successfully but is not in allow.
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
basicAuthHeader("guest", "abc"),
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusForbidden), resp.StatusCode)
|
||||||
|
require.True(t, test.HasHeader(resp.Headers, "WWW-Authenticate"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthFalse_ConsumerNotAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, false, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
basicAuthHeader("guest", "abc"),
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusForbidden), resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — onHttpRequestHeaders branch coverage ====================
|
||||||
|
//
|
||||||
|
// Baseline onHttpRequestHeaders is 66.0% and only covers the early-exit and
|
||||||
|
// "no allow list / no rule" branches. Sub-cases below drive each remaining
|
||||||
|
// branch documented in the comment block at main.go:182-193:
|
||||||
|
// authenticated case 2 (global_auth=true with allow), authenticated case 3
|
||||||
|
// (global_auth=false with allow), no-rules + globalAuth-unset path, and the
|
||||||
|
// three failure helpers that produce 401 (no auth data / decode error / bad
|
||||||
|
// format) under global_auth=true (so the early-exit guard does not fire).
|
||||||
|
|
||||||
|
// authenticated case 2: global_auth=true + route-scoped allow + consumer in allow
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_ConsumerAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
basicAuthHeader("admin", "123456"),
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
require.True(t, test.HasHeaderWithValue(host.GetRequestHeaders(), "X-Mse-Consumer", "consumer1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticated case 3: global_auth=false + route-scoped allow + consumer in allow
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthFalse_ConsumerAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, false, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
basicAuthHeader("admin", "123456"),
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
require.True(t, test.HasHeaderWithValue(host.GetRequestHeaders(), "X-Mse-Consumer", "consumer1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticated case 1 fallback: global_auth unset, no rules anywhere, valid
|
||||||
|
// credentials → consumer authenticated and X-Mse-Consumer injected.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthUnset_NoRules_Authenticated(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credential": "admin:123456"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
basicAuthHeader("admin", "123456"),
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
require.True(t, test.HasHeaderWithValue(host.GetRequestHeaders(), "X-Mse-Consumer", "consumer1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing Authorization header under global_auth=true must hit
|
||||||
|
// deniedNoBasicAuthData (401).
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_MissingAuthorization(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusUnauthorized), resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid base64 payload (not just a missing prefix) — exercises the decode
|
||||||
|
// error branch which is distinct from the format-prefix branch already
|
||||||
|
// covered by main_test.go.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_DecodeError(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"authorization", "Basic !!!notbase64"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusUnauthorized), resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// payload decodes but does not split on ':' — exercises the
|
||||||
|
// "len(userAndPasswd) != 2" branch which has different status-code-detail
|
||||||
|
// "basic-auth.bad_credential" semantics from the no-data branch.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_BadCredentialFormat(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(ruleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
// "adminonly" decodes successfully but contains no colon.
|
||||||
|
bad := base64.StdEncoding.EncodeToString([]byte("adminonly"))
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"authorization", "Basic " + bad},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusUnauthorized), resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 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 config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Module A — FillDefaultValue =========================================
|
||||||
|
//
|
||||||
|
// FillDefaultValue is 0% in baseline. The existing ProcessTest never calls
|
||||||
|
// it; every fixture pre-fills BlockedCode and BlockedMessage. The function
|
||||||
|
// is the contract for "missing config → safe defaults" and a regression
|
||||||
|
// here would silently change either the deny status code (403) or the
|
||||||
|
// reject message ("Invalid User-Agent"), both of which are user-visible.
|
||||||
|
|
||||||
|
// Zero-value config gets BOTH defaults populated.
|
||||||
|
func TestFillDefaultValue_ZeroConfigSetsBoth(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{}
|
||||||
|
c.FillDefaultValue()
|
||||||
|
require.Equal(t, uint32(403), c.BlockedCode)
|
||||||
|
require.Equal(t, "Invalid User-Agent", c.BlockedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-supplied non-zero BlockedCode must NOT be overwritten — the default
|
||||||
|
// only fills the unset slot. Distinct from the message-only case below.
|
||||||
|
func TestFillDefaultValue_PreservesCustomCode(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{BlockedCode: 429}
|
||||||
|
c.FillDefaultValue()
|
||||||
|
require.Equal(t, uint32(429), c.BlockedCode)
|
||||||
|
// Message was empty → still gets default.
|
||||||
|
require.Equal(t, "Invalid User-Agent", c.BlockedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror of the code case for the message field.
|
||||||
|
func TestFillDefaultValue_PreservesCustomMessage(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{BlockedMessage: "go away"}
|
||||||
|
c.FillDefaultValue()
|
||||||
|
require.Equal(t, "go away", c.BlockedMessage)
|
||||||
|
require.Equal(t, uint32(403), c.BlockedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both fields set → no-op (every default-fill branch's `if` evaluates
|
||||||
|
// false). Pins idempotency: calling FillDefaultValue twice on a fully
|
||||||
|
// configured struct must not mutate it.
|
||||||
|
func TestFillDefaultValue_FullyConfiguredIsNoop(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{BlockedCode: 418, BlockedMessage: "teapot"}
|
||||||
|
c.FillDefaultValue()
|
||||||
|
c.FillDefaultValue() // twice on purpose
|
||||||
|
require.Equal(t, uint32(418), c.BlockedCode)
|
||||||
|
require.Equal(t, "teapot", c.BlockedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — Process fall-through =====================================
|
||||||
|
//
|
||||||
|
// Process is 91.7%. The existing ProcessTest hits every branch EXCEPT the
|
||||||
|
// final `return true, ""` at bot_detect_config.go:67 — the "non-empty UA,
|
||||||
|
// no allow match, no deny match, no default-bot match" case. This is the
|
||||||
|
// happy path for real browser traffic; without coverage, a refactor that
|
||||||
|
// flipped the default verdict would slip through.
|
||||||
|
func TestProcess_RealBrowserUA_AllowedByDefault(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{}
|
||||||
|
// A typical Chrome desktop UA — no "bot/spider/crawler" substrings,
|
||||||
|
// no version suffix that the default regex matrix targets.
|
||||||
|
ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
ok, reason := c.Process(ua)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Empty(t, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow list configured but the rule does NOT match the UA → loop
|
||||||
|
// completes, falls through to the deny + default checks. Mirrors the
|
||||||
|
// existing "allow + match" case but pins the no-match branch through the
|
||||||
|
// allow loop (statement at line 52.38). Existing test always supplies an
|
||||||
|
// allow rule that matches — never one that doesn't.
|
||||||
|
func TestProcess_AllowListNoMatch_FallsThrough(t *testing.T) {
|
||||||
|
c := &BotDetectConfig{
|
||||||
|
Allow: []*regexp.Regexp{regexp.MustCompile(`^MyAllowedAgent$`)},
|
||||||
|
}
|
||||||
|
// UA matches NEITHER the allow rule NOR any default bot regex.
|
||||||
|
ok, reason := c.Process("Mozilla/5.0 (compatible)")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Empty(t, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny rule fires before any default-bot rule could match. Existing
|
||||||
|
// "test deny bot detect" passes "Chrome" + deny=["Chrome"], but Chrome
|
||||||
|
// also doesn't match any default regex — so the test doesn't actually
|
||||||
|
// prove the user-deny path is checked BEFORE the default list. This UA
|
||||||
|
// would match a default regex (`indexer/1.2`) AND a user-deny regex; if
|
||||||
|
// the order were swapped, the returned reason string would change from
|
||||||
|
// the user-supplied rule to the default rule.
|
||||||
|
func TestProcess_UserDenyTakesPrecedenceOverDefault(t *testing.T) {
|
||||||
|
customRule := `^indexer/`
|
||||||
|
c := &BotDetectConfig{
|
||||||
|
Deny: []*regexp.Regexp{regexp.MustCompile(customRule)},
|
||||||
|
}
|
||||||
|
ok, reason := c.Process("indexer/1.2")
|
||||||
|
require.False(t, ok)
|
||||||
|
// The reason must be the USER-CONFIGURED rule, not whichever default
|
||||||
|
// regex would also have matched. Pins the "user deny first, default
|
||||||
|
// list second" iteration order.
|
||||||
|
require.Equal(t, customRule, reason)
|
||||||
|
}
|
||||||
246
plugins/wasm-go/extensions/custom-response/main_extra_test.go
Normal file
246
plugins/wasm-go/extensions/custom-response/main_extra_test.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"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 — fuzzyMatchCode early-exit and skip branches =============
|
||||||
|
//
|
||||||
|
// fuzzyMatchCode is 88.2% in baseline. Test_prefixMatchCode in main_test.go
|
||||||
|
// drives the matching matrix but never calls the function with an empty
|
||||||
|
// map, an empty status code, a length-mismatched code, or a pure-numeric
|
||||||
|
// pattern that must be skipped via the `!strings.Contains(pattern, "x")`
|
||||||
|
// guard at main.go:234-236.
|
||||||
|
|
||||||
|
func TestFuzzyMatchCode_EmptyMap(t *testing.T) {
|
||||||
|
rule, ok := fuzzyMatchCode(map[string]*CustomResponseRule{}, "404")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Nil(t, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFuzzyMatchCode_EmptyStatusCode(t *testing.T) {
|
||||||
|
m := map[string]*CustomResponseRule{"4xx": {}}
|
||||||
|
rule, ok := fuzzyMatchCode(m, "")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Nil(t, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is longer than every pattern in the map ⇒ `len(pattern) != codeLen`
|
||||||
|
// is true on every iter → continue → no match. Pins the per-iter length
|
||||||
|
// guard at main.go:230-232 (the existing tests use 3-char codes against a
|
||||||
|
// 3-char-only map so the guard's body never fires).
|
||||||
|
func TestFuzzyMatchCode_LengthMismatch(t *testing.T) {
|
||||||
|
m := map[string]*CustomResponseRule{"4xx": {}, "5xx": {}}
|
||||||
|
rule, ok := fuzzyMatchCode(m, "4040")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Nil(t, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure-numeric pattern (no `x`) is skipped — pins the
|
||||||
|
// `!strings.Contains(pattern, "x")` short-circuit. Without the skip the
|
||||||
|
// fuzzy block would shadow the upstream exact-match dispatch.
|
||||||
|
func TestFuzzyMatchCode_PureNumberPatternSkipped(t *testing.T) {
|
||||||
|
m := map[string]*CustomResponseRule{
|
||||||
|
"500": {}, // pure-numeric — must be skipped by fuzzy logic
|
||||||
|
"4xx": {}, // x-containing — would match if reached
|
||||||
|
}
|
||||||
|
// "500" matches the first map entry exactly (in length) but fuzzy
|
||||||
|
// logic skips numeric patterns; "4xx" doesn't match "500"; result:
|
||||||
|
// no fuzzy match.
|
||||||
|
rule, ok := fuzzyMatchCode(m, "500")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Nil(t, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — parseRuleItem error paths ===============================
|
||||||
|
//
|
||||||
|
// parseRuleItem is 92.3%. Three uncovered branches:
|
||||||
|
// - content-length header skip at main.go:105-106
|
||||||
|
// - header without `=` returning error at main.go:110-112
|
||||||
|
// - status_code value not parseable as int at main.go:128-131
|
||||||
|
|
||||||
|
// Driven through ParseConfig + NewTestHost so the wasm logger is set up.
|
||||||
|
// Old-style (single-rule) config with content-length in headers — the
|
||||||
|
// header must be silently dropped, NOT round-tripped, since that field is
|
||||||
|
// recomputed by the proxy.
|
||||||
|
func TestParseRuleItem_ContentLengthHeaderSkipped(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"status_code": 200,
|
||||||
|
"headers": []string{
|
||||||
|
"Content-Length=999", // must be dropped
|
||||||
|
"X-Other=keep",
|
||||||
|
},
|
||||||
|
"body": "ok",
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := conf.(*CustomResponseConfig)
|
||||||
|
require.NotNil(t, rc.defaultRule)
|
||||||
|
// content-length must NOT appear in the rule's headers.
|
||||||
|
for _, h := range rc.defaultRule.headers {
|
||||||
|
require.NotEqual(t, "Content-Length", h[0])
|
||||||
|
require.NotEqual(t, "content-length", h[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header missing `=` ⇒ parseRuleItem returns "invalid header pair format"
|
||||||
|
// → parseConfig propagates → plugin start fails.
|
||||||
|
func TestParseRuleItem_HeaderMissingEquals_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"status_code": 200,
|
||||||
|
"headers": []string{"no_equals_sign"},
|
||||||
|
"body": "x",
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// status_code that doesn't parse as int ⇒ strconv.Atoi error →
|
||||||
|
// parseRuleItem returns wrapped error → start fails.
|
||||||
|
func TestParseRuleItem_InvalidStatusCode_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"status_code": "not-a-number",
|
||||||
|
"body": "x",
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — parseConfig error paths =================================
|
||||||
|
//
|
||||||
|
// parseConfig is 84.0%. Three reachable uncovered branches:
|
||||||
|
// - rules-array path: parseRuleItem error propagation at main.go:62-64
|
||||||
|
// (the existing `invalidConfig` exercises ONLY single-rule path; the
|
||||||
|
// array form's error propagation is distinct)
|
||||||
|
// - duplicate enable_on_status across rules at main.go:82-85
|
||||||
|
// - rules-version with no defaultRule and empty enableOnStatusRuleMap
|
||||||
|
// at main.go:89-91 — reachable only via empty rules array
|
||||||
|
|
||||||
|
// Two rules sharing the same exact enable_on_status entry must fail with
|
||||||
|
// "enableOnStatus can only use once" — pins single-rule-per-status
|
||||||
|
// uniqueness invariant.
|
||||||
|
func TestParseConfig_DuplicateEnableOnStatus_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"rules": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"status_code": 200,
|
||||||
|
"body": "a",
|
||||||
|
"enable_on_status": []string{"404"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status_code": 200,
|
||||||
|
"body": "b",
|
||||||
|
"enable_on_status": []string{"404"}, // duplicate
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// One rule in the array has a malformed header ⇒ parseRuleItem returns
|
||||||
|
// err → parseConfig propagates from the rules-array branch (distinct from
|
||||||
|
// the single-rule branch already covered by `invalidConfig`).
|
||||||
|
func TestParseConfig_RuleInArrayInvalid_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"rules": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"status_code": 200,
|
||||||
|
"headers": []string{"no_equals"},
|
||||||
|
"body": "x",
|
||||||
|
"enable_on_status": []string{"200"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules array is present but empty ⇒ rulesVersion=true, no rules iterated,
|
||||||
|
// defaultRule stays nil, enableOnStatusRuleMap stays empty → returns
|
||||||
|
// "no valid config is found".
|
||||||
|
func TestParseConfig_EmptyRulesArray_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"rules": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module D — onHttpResponseHeaders missing :status ===================
|
||||||
|
//
|
||||||
|
// onHttpResponseHeaders is 73.3%. The early-exit at main.go:200-204 —
|
||||||
|
// `:status` retrieval failure → log + ActionContinue — is unreached
|
||||||
|
// because every existing fixture passes a `:status` header. This is the
|
||||||
|
// fail-soft contract under malformed upstream responses.
|
||||||
|
func TestOnHttpResponseHeaders_MissingStatusHeader_PassThrough(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(statusMatchConfig)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// No :status in response headers.
|
||||||
|
action := host.CallOnHttpResponseHeaders([][2]string{
|
||||||
|
{"content-type", "text/plain"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
// No local response sent — the rule path didn't fire because the
|
||||||
|
// status retrieval failed first.
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
})
|
||||||
|
}
|
||||||
207
plugins/wasm-go/extensions/ext-auth/config/config_extra_test.go
Normal file
207
plugins/wasm-go/extensions/ext-auth/config/config_extra_test.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// 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 config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Module A — parseAuthorizationResponseConfig =======================
|
||||||
|
//
|
||||||
|
// parseAuthorizationResponseConfig sits at 17.6% in the baseline because
|
||||||
|
// every existing fixture either omits authorization_response entirely or
|
||||||
|
// tests it implicitly through ParseConfig with only one of the two list
|
||||||
|
// shapes. The tests below drive each branch directly via ParseConfig:
|
||||||
|
// - allowed_upstream_headers list set
|
||||||
|
// - allowed_client_headers list set
|
||||||
|
// - both lists set
|
||||||
|
// - error propagation when one of the lists has a bad regex matcher
|
||||||
|
// (the only failure mode the function can surface)
|
||||||
|
|
||||||
|
func parseFromJSON(t *testing.T, jsonStr string) (ExtAuthConfig, error) {
|
||||||
|
t.Helper()
|
||||||
|
var cfg ExtAuthConfig
|
||||||
|
err := ParseConfig(gjson.Parse(jsonStr), &cfg)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthorizationResponse_AllowedUpstreamHeaders(t *testing.T) {
|
||||||
|
cfg, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_response": {
|
||||||
|
"allowed_upstream_headers": [
|
||||||
|
{"exact": "x-user-id"},
|
||||||
|
{"prefix": "x-auth-"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders)
|
||||||
|
// Sanity-check matcher behavior end-to-end.
|
||||||
|
require.True(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders.Match("x-user-id"))
|
||||||
|
require.True(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders.Match("x-auth-token"))
|
||||||
|
require.False(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders.Match("authorization"))
|
||||||
|
// Client side intentionally untouched.
|
||||||
|
require.Nil(t, cfg.HttpService.AuthorizationResponse.AllowedClientHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthorizationResponse_AllowedClientHeaders(t *testing.T) {
|
||||||
|
cfg, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_response": {
|
||||||
|
"allowed_client_headers": [
|
||||||
|
{"exact": "www-authenticate"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders)
|
||||||
|
require.NotNil(t, cfg.HttpService.AuthorizationResponse.AllowedClientHeaders)
|
||||||
|
require.True(t, cfg.HttpService.AuthorizationResponse.AllowedClientHeaders.Match("www-authenticate"))
|
||||||
|
require.False(t, cfg.HttpService.AuthorizationResponse.AllowedClientHeaders.Match("x-user-id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthorizationResponse_BothListsSet(t *testing.T) {
|
||||||
|
cfg, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_response": {
|
||||||
|
"allowed_upstream_headers": [{"exact": "x-user-id"}],
|
||||||
|
"allowed_client_headers": [{"prefix": "www-"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg.HttpService.AuthorizationResponse.AllowedUpstreamHeaders)
|
||||||
|
require.NotNil(t, cfg.HttpService.AuthorizationResponse.AllowedClientHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad regex inside allowed_upstream_headers must propagate the
|
||||||
|
// BuildRepeatedStringMatcherIgnoreCase error and fail ParseConfig — pins
|
||||||
|
// the err path at config.go:239-241.
|
||||||
|
func TestParseAuthorizationResponse_AllowedUpstreamBadRegex(t *testing.T) {
|
||||||
|
_, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_response": {
|
||||||
|
"allowed_upstream_headers": [{"regex": "[unbalanced"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same propagation contract for allowed_client_headers — distinct branch
|
||||||
|
// at config.go:248-250.
|
||||||
|
func TestParseAuthorizationResponse_AllowedClientBadRegex(t *testing.T) {
|
||||||
|
_, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_response": {
|
||||||
|
"allowed_client_headers": [{"regex": "[unbalanced"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — parseAuthorizationRequestConfig allowed_headers error ===
|
||||||
|
//
|
||||||
|
// Mirrors the response-side bad-regex case for the request side — the
|
||||||
|
// `allowed_headers` failure path at config.go:194-197 is unreached because
|
||||||
|
// every existing fixture supplies well-formed exact/prefix matchers.
|
||||||
|
|
||||||
|
func TestParseAuthorizationRequest_AllowedHeadersBadRegex(t *testing.T) {
|
||||||
|
_, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
},
|
||||||
|
"authorization_request": {
|
||||||
|
"allowed_headers": [{"regex": "[unbalanced"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — parseEndpointConfig small edges ========================
|
||||||
|
//
|
||||||
|
// forward_auth without explicit request_method falls back to GET (default
|
||||||
|
// http.MethodGet at config.go:169).
|
||||||
|
func TestParseEndpointConfig_ForwardAuthDefaultsToGET(t *testing.T) {
|
||||||
|
cfg, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "forward_auth",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"service_port": 8090,
|
||||||
|
"path": "/auth"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "GET", cfg.HttpService.RequestMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
// service_port omitted defaults to 80 (config.go:144-146).
|
||||||
|
func TestParseEndpointConfig_ServicePortDefaults80(t *testing.T) {
|
||||||
|
cfg, err := parseFromJSON(t, `{
|
||||||
|
"http_service": {
|
||||||
|
"endpoint_mode": "envoy",
|
||||||
|
"endpoint": {
|
||||||
|
"service_name": "ext-auth.example.com",
|
||||||
|
"path_prefix": "/auth"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg.HttpService.Client)
|
||||||
|
}
|
||||||
93
plugins/wasm-go/extensions/ext-auth/expr/expr_extra_test.go
Normal file
93
plugins/wasm-go/extensions/ext-auth/expr/expr_extra_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// 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 expr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Module A — MatchRulesDefaults ======================================
|
||||||
|
//
|
||||||
|
// MatchRulesDefaults is at 0% in the baseline. It is consumed by
|
||||||
|
// config.ParseConfig as the zero-value MatchRules when the user supplies
|
||||||
|
// no match_list, so a regression here would silently change route-skip
|
||||||
|
// semantics from "whitelist with empty rule list" (block all by default)
|
||||||
|
// to whatever a future zero-value happens to mean.
|
||||||
|
|
||||||
|
func TestMatchRulesDefaults_WhitelistMode(t *testing.T) {
|
||||||
|
d := MatchRulesDefaults()
|
||||||
|
require.Equal(t, ModeWhitelist, d.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRulesDefaults_EmptyButNonNilRuleList(t *testing.T) {
|
||||||
|
d := MatchRulesDefaults()
|
||||||
|
require.NotNil(t, d.RuleList)
|
||||||
|
require.Len(t, d.RuleList, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In whitelist mode with an empty rule list, every (domain, method, path)
|
||||||
|
// triple must be DENIED by the rule check (i.e. the auth server gets to see
|
||||||
|
// the request). The dual contract — blacklist + empty rule list = ALLOW —
|
||||||
|
// is already covered by match_rules_test.go via populated rule sets, but
|
||||||
|
// the empty-list defaults case is an important degenerate edge.
|
||||||
|
func TestMatchRulesDefaults_EmptyWhitelistDenies(t *testing.T) {
|
||||||
|
d := MatchRulesDefaults()
|
||||||
|
require.False(t, d.IsAllowedByMode("example.com", "GET", "/x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — IsAllowedByMode default branch ==========================
|
||||||
|
//
|
||||||
|
// `default: return false` at match_rules.go:51 is unreachable through
|
||||||
|
// MatchRulesDefaults because Mode is whitelist there. A misconfigured /
|
||||||
|
// hand-built MatchRules with an unknown mode must safely fall back to
|
||||||
|
// "not allowed" so the request still goes through the auth server rather
|
||||||
|
// than silently bypassing it.
|
||||||
|
|
||||||
|
func TestIsAllowedByMode_UnknownModeFallsToFalse(t *testing.T) {
|
||||||
|
mr := MatchRules{Mode: "not-a-mode", RuleList: []Rule{}}
|
||||||
|
require.False(t, mr.IsAllowedByMode("example.com", "GET", "/x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — BuildStringMatcher edges ================================
|
||||||
|
//
|
||||||
|
// BuildStringMatcher is at 75%; the unknown-type error branch and the
|
||||||
|
// invalid-regex branch are both unreached. Both must produce errors rather
|
||||||
|
// than nil-matchers so config.parseMatchRules can surface a proper config
|
||||||
|
// validation error.
|
||||||
|
|
||||||
|
func TestBuildStringMatcher_UnknownType(t *testing.T) {
|
||||||
|
m, err := BuildStringMatcher("not-a-pattern", "x", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, m)
|
||||||
|
require.Contains(t, err.Error(), "unknown string matcher type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStringMatcher_InvalidRegex(t *testing.T) {
|
||||||
|
// Unbalanced "[" is a regexp.Compile error.
|
||||||
|
m, err := BuildStringMatcher(MatchPatternRegex, "[unbalanced", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IgnoreCase + already-prefixed `(?i)` regex must NOT double-prefix —
|
||||||
|
// pins matcher.go:119-121's idempotency check.
|
||||||
|
func TestBuildStringMatcher_RegexIgnoreCaseAlreadyPrefixed(t *testing.T) {
|
||||||
|
m, err := BuildStringMatcher(MatchPatternRegex, "(?i)foo", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, m)
|
||||||
|
require.True(t, m.Match("FOO"))
|
||||||
|
}
|
||||||
131
plugins/wasm-go/extensions/ext-auth/util/utils_test.go
Normal file
131
plugins/wasm-go/extensions/ext-auth/util/utils_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// 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 util's first test file. SendResponse calls into proxywasm and
|
||||||
|
// requires a host emulator, so it is exercised end-to-end through main
|
||||||
|
// package tests; the three deterministic helpers (ReconvertHeaders,
|
||||||
|
// ExtractFromHeader, ContainsString) are unit-tested directly here so that
|
||||||
|
// future refactors to the helpers themselves don't depend on dragging in
|
||||||
|
// the wasm host harness.
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Module A — ReconvertHeaders ========================================
|
||||||
|
|
||||||
|
// nil http.Header must produce a non-panicking nil/empty slice; downstream
|
||||||
|
// proxywasm calls accept either.
|
||||||
|
func TestReconvertHeaders_Nil(t *testing.T) {
|
||||||
|
require.Empty(t, ReconvertHeaders(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconvertHeaders_Empty(t *testing.T) {
|
||||||
|
require.Empty(t, ReconvertHeaders(http.Header{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-key + multi-value: each (key, value) pair becomes a separate
|
||||||
|
// [2]string entry, and the result is sorted stably by key — required so
|
||||||
|
// proxywasm sees a deterministic order regardless of map iteration.
|
||||||
|
func TestReconvertHeaders_MultiValueSorted(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Add("X-A", "1")
|
||||||
|
h.Add("X-A", "2")
|
||||||
|
h.Set("X-B", "b")
|
||||||
|
h.Set("X-C", "c")
|
||||||
|
|
||||||
|
got := ReconvertHeaders(h)
|
||||||
|
// Two values for X-A → two entries; one each for X-B / X-C.
|
||||||
|
require.Len(t, got, 4)
|
||||||
|
// Sorted by key, ascending.
|
||||||
|
require.Equal(t, "X-A", got[0][0])
|
||||||
|
require.Equal(t, "X-A", got[1][0])
|
||||||
|
require.Equal(t, "X-B", got[2][0])
|
||||||
|
require.Equal(t, "X-C", got[3][0])
|
||||||
|
// Values for the same key preserve their insertion order.
|
||||||
|
require.Equal(t, "1", got[0][1])
|
||||||
|
require.Equal(t, "2", got[1][1])
|
||||||
|
require.Equal(t, "b", got[2][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — ExtractFromHeader =======================================
|
||||||
|
|
||||||
|
// Hit on the literal-case key the caller asked for. The lookup compares the
|
||||||
|
// header key to its lower-case form, so callers must pass already-lowercased
|
||||||
|
// keys; `ExtractFromHeader(headers, "x-foo")` matches both "X-Foo" and
|
||||||
|
// "x-foo" but `(headers, "X-Foo")` matches neither.
|
||||||
|
func TestExtractFromHeader_LowercaseKeyHit(t *testing.T) {
|
||||||
|
headers := [][2]string{
|
||||||
|
{"Authorization", "Bearer token"},
|
||||||
|
{"X-Foo", "bar"},
|
||||||
|
}
|
||||||
|
require.Equal(t, "Bearer token", ExtractFromHeader(headers, "authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed-case stored key still matches because the comparison lowercases the
|
||||||
|
// stored key, not the search key — pins the asymmetry above.
|
||||||
|
func TestExtractFromHeader_StoredMixedCase(t *testing.T) {
|
||||||
|
headers := [][2]string{{"X-Foo", "bar"}}
|
||||||
|
require.Equal(t, "bar", ExtractFromHeader(headers, "x-foo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading and trailing whitespace in the stored value is trimmed so the
|
||||||
|
// caller doesn't have to defensively re-trim.
|
||||||
|
func TestExtractFromHeader_TrimsWhitespace(t *testing.T) {
|
||||||
|
headers := [][2]string{{"X-Token", " trimmed-value "}}
|
||||||
|
require.Equal(t, "trimmed-value", ExtractFromHeader(headers, "x-token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miss → empty string, not error: callers branch on `value != ""`.
|
||||||
|
func TestExtractFromHeader_Miss(t *testing.T) {
|
||||||
|
headers := [][2]string{{"X-Foo", "bar"}}
|
||||||
|
require.Equal(t, "", ExtractFromHeader(headers, "x-missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFromHeader_EmptySlice(t *testing.T) {
|
||||||
|
require.Equal(t, "", ExtractFromHeader(nil, "x-foo"))
|
||||||
|
require.Equal(t, "", ExtractFromHeader([][2]string{}, "x-foo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — ContainsString ==========================================
|
||||||
|
|
||||||
|
// Hit semantics: case-insensitive equality, NOT substring.
|
||||||
|
func TestContainsString_Hit(t *testing.T) {
|
||||||
|
require.True(t, ContainsString([]string{"GET", "POST"}, "POST"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainsString_HitCaseInsensitive(t *testing.T) {
|
||||||
|
require.True(t, ContainsString([]string{"GET", "POST"}, "post"))
|
||||||
|
require.True(t, ContainsString([]string{"GeT"}, "get"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "PO" is not a member, only a prefix — must miss. Pins that the helper
|
||||||
|
// is equality-based, not strings.Contains-based, in case of refactor drift.
|
||||||
|
func TestContainsString_PrefixIsNotMember(t *testing.T) {
|
||||||
|
require.False(t, ContainsString([]string{"POST"}, "PO"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainsString_Miss(t *testing.T) {
|
||||||
|
require.False(t, ContainsString([]string{"GET", "POST"}, "PUT"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainsString_EmptySlice(t *testing.T) {
|
||||||
|
require.False(t, ContainsString(nil, "x"))
|
||||||
|
require.False(t, ContainsString([]string{}, "x"))
|
||||||
|
}
|
||||||
205
plugins/wasm-go/extensions/ip-restriction/main_extra_test.go
Normal file
205
plugins/wasm-go/extensions/ip-restriction/main_extra_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||||
|
"github.com/higress-group/wasm-go/pkg/test"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === 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 — parseIPNets edges =======================================
|
||||||
|
//
|
||||||
|
// parseIPNets is 70.0% in baseline. utils_test.go exercises only the
|
||||||
|
// happy-path CIDR list and the empty-array case; both error branches
|
||||||
|
// (`AddByString` failure on bad input + `ErrNodeBusy` log-and-continue on
|
||||||
|
// duplicate) are unreached. They share the function's only error-handling
|
||||||
|
// chain at utils.go:23-29 so they must be pinned together.
|
||||||
|
|
||||||
|
// Duplicate IP entries → second `AddByString` returns nradix.ErrNodeBusy →
|
||||||
|
// the function logs the duplicate and continues, eventually returning a
|
||||||
|
// well-formed tree. Driven through ParseConfig+NewTestHost rather than
|
||||||
|
// calling parseIPNets directly because the function calls log.Warnf which
|
||||||
|
// panics without a host-initialized logger; a passing host start with
|
||||||
|
// duplicate allow entries proves the same contract.
|
||||||
|
func TestParseConfig_DuplicateAllowIP_StartsOK(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"allow": []string{"10.0.0.1", "10.0.0.1"},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := conf.(*RestrictionConfig)
|
||||||
|
require.NotNil(t, rc.Allow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bogus string (not an IP, not CIDR) → AddByString returns an error that
|
||||||
|
// is NOT ErrNodeBusy → function returns nil + wrapped error. Distinct from
|
||||||
|
// the duplicate case above: a structurally invalid entry is fatal, while
|
||||||
|
// a duplicate is tolerated. Direct call works because the non-busy error
|
||||||
|
// path does NOT log — it returns the wrapped error before any log call.
|
||||||
|
func TestParseIPNets_InvalidEntry(t *testing.T) {
|
||||||
|
tree, err := parseIPNets(gjson.Parse(`["not-an-ip"]`).Array())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, tree)
|
||||||
|
require.Contains(t, err.Error(), "not-an-ip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — parseConfig edges =======================================
|
||||||
|
//
|
||||||
|
// parseConfig is 86.1%. Three uncovered branches:
|
||||||
|
// - `default:` switch arm at main.go:52-54 — unknown ip_source_type value
|
||||||
|
// falls back to OriginSourceType (distinct from the unset/empty path
|
||||||
|
// covered by defaultConfig in main_test.go).
|
||||||
|
// - allow parseIPNets error propagation at main.go:78-81.
|
||||||
|
// - deny parseIPNets error propagation at main.go:83-86.
|
||||||
|
|
||||||
|
// Unknown ip_source_type value (neither "header" nor "origin-source") must
|
||||||
|
// fall through to OriginSourceType — this is the safety default so a
|
||||||
|
// typo'd config doesn't accidentally trust an arbitrary header.
|
||||||
|
func TestParseConfig_UnknownSourceType_FallsToOrigin(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"ip_source_type": "totally-unknown-mode",
|
||||||
|
"allow": []string{"127.0.0.1"},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := conf.(*RestrictionConfig)
|
||||||
|
require.Equal(t, OriginSourceType, rc.IPSourceType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad allow IP propagates parseIPNets's error → parseConfig returns the
|
||||||
|
// err → host plugin start status = Failed. Mirrors the dual contract on
|
||||||
|
// the deny side below.
|
||||||
|
func TestParseConfig_InvalidAllowIP_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"allow": []string{"not-an-ip"},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfig_InvalidDenyIP_StartFails(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"deny": []string{"not-an-ip"},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — getDownStreamIp / onHttpRequestHeaders error paths ======
|
||||||
|
//
|
||||||
|
// onHttpRequestHeaders is 77.8% and getDownStreamIp is 92.3%. Existing
|
||||||
|
// tests always either set source/address (origin mode) or pass the IP
|
||||||
|
// header (header mode). The "header missing" and "origin property
|
||||||
|
// missing" paths are unreached — both must funnel into the
|
||||||
|
// `deniedUnauthorized(get_ip_failed)` early return at main.go:126-128.
|
||||||
|
|
||||||
|
// Header source mode + IP header absent ⇒ GetHttpRequestHeader returns
|
||||||
|
// err → onHttpRequestHeaders 403s with reason "get_ip_failed". Distinct
|
||||||
|
// from "deny list - IP not denied" which sends a valid header.
|
||||||
|
func TestOnHttpRequestHeaders_HeaderSource_HeaderMissing_403(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
// Use deny mode with header source. Don't pass X-Real-IP.
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"ip_source_type": "header",
|
||||||
|
"ip_header_name": "X-Real-IP",
|
||||||
|
"deny": []string{"10.0.0.1"},
|
||||||
|
"status": 403,
|
||||||
|
"message": "blocked",
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(403), resp.StatusCode)
|
||||||
|
// Detail string is "key-auth.<reason>" from deniedUnauthorized.
|
||||||
|
require.Contains(t, resp.StatusCodeDetail, "get_ip_failed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module D — DefaultDenyMessage default constant ====================
|
||||||
|
//
|
||||||
|
// defaultConfig in main_test.go runs ParseConfig only; the default Status
|
||||||
|
// (403) and Message ("Your IP address is blocked.") are never observed
|
||||||
|
// through an actual deny path. Pin both via a deny verdict using a
|
||||||
|
// minimal config that omits status and message.
|
||||||
|
func TestOnHttpRequestHeaders_DefaultStatusAndMessage_OnDeny(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfigBytes(t, map[string]interface{}{
|
||||||
|
"ip_source_type": "origin-source",
|
||||||
|
"allow": []string{"127.0.0.1"},
|
||||||
|
// status + message intentionally omitted.
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
// IP NOT in allow list → blocked with default 403 + default msg.
|
||||||
|
host.SetProperty([]string{"source", "address"}, []byte("8.8.8.8:1234"))
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(DefaultDenyStatus), resp.StatusCode)
|
||||||
|
var body map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(resp.Data, &body))
|
||||||
|
require.Equal(t, DefaultDenyMessage, body["message"])
|
||||||
|
})
|
||||||
|
}
|
||||||
281
plugins/wasm-go/extensions/key-auth/main_extra_test.go
Normal file
281
plugins/wasm-go/extensions/key-auth/main_extra_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"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 =============================================================
|
||||||
|
|
||||||
|
// mustConfig marshals m to JSON and fails the test on error.
|
||||||
|
func mustConfig(t *testing.T, m map[string]interface{}) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeRuleConfig produces a config with two consumers and a single _rules_ entry
|
||||||
|
// scoped to "route-a". globalAuth=nil leaves global_auth unset; allow controls
|
||||||
|
// who is permitted on route-a.
|
||||||
|
func routeRuleConfig(t *testing.T, globalAuth interface{}, allow []string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credential": "token1"},
|
||||||
|
{"name": "consumer2", "credential": "token2"},
|
||||||
|
},
|
||||||
|
"keys": []string{"x-api-key"},
|
||||||
|
"in_header": true,
|
||||||
|
"_rules_": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"_match_route_": []string{"route-a"},
|
||||||
|
"allow": allow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if globalAuth != nil {
|
||||||
|
cfg["global_auth"] = globalAuth
|
||||||
|
}
|
||||||
|
return mustConfig(t, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module A — contains helper =========================================
|
||||||
|
//
|
||||||
|
// `contains` ships at 0% in the baseline; it is reachable only through the
|
||||||
|
// allow-list branches of onHttpRequestHeaders, which Module B drives. A direct
|
||||||
|
// unit test pins behavior independently in case the wasm dispatch path
|
||||||
|
// regresses or the helper is reused elsewhere (e.g. a future plugin pulling
|
||||||
|
// in the same util pattern, mirroring basic-auth's contains).
|
||||||
|
|
||||||
|
func TestContains_Hit(t *testing.T) {
|
||||||
|
require.True(t, contains([]string{"a", "b", "c"}, "b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_Miss(t *testing.T) {
|
||||||
|
require.False(t, contains([]string{"a", "b", "c"}, "z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_EmptySlice(t *testing.T) {
|
||||||
|
require.False(t, contains([]string{}, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains_NilSlice(t *testing.T) {
|
||||||
|
require.False(t, contains(nil, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — onHttpRequestHeaders allow-list branches ================
|
||||||
|
//
|
||||||
|
// Baseline onHttpRequestHeaders is 68.9%. Existing main_test.go only drives
|
||||||
|
// the early-exit and "no allow list" paths plus token-extraction failures.
|
||||||
|
// The four allow-list dispatches at main.go:344-363 — global_auth=true vs
|
||||||
|
// global_auth=false, consumer in vs not in allow — are uncovered. Each of
|
||||||
|
// these reaches `contains`, so this module also exercises the helper through
|
||||||
|
// its production caller.
|
||||||
|
|
||||||
|
// global_auth=true + route-scoped allow + consumer in allow → authenticated
|
||||||
|
// + X-Mse-Consumer header injected (main.go:344-351 success branch).
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_RouteAllow_ConsumerAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(routeRuleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"x-api-key", "token1"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
require.True(t, test.HasHeaderWithValue(host.GetRequestHeaders(), "X-Mse-Consumer", "consumer1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// global_auth=true + route-scoped allow + consumer not in allow → 403 via
|
||||||
|
// deniedUnauthorizedConsumer (main.go:344-348). The credential decodes and
|
||||||
|
// authenticates against credential2Name — `consumer2` exists but is not
|
||||||
|
// permitted on route-a — distinct from the "credential not configured"
|
||||||
|
// variant already covered by main_test.go's "invalid api key" case.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthTrue_RouteAllow_ConsumerNotAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(routeRuleConfig(t, true, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"x-api-key", "token2"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusForbidden), resp.StatusCode)
|
||||||
|
require.True(t, test.HasHeader(resp.Headers, "WWW-Authenticate"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// global_auth=false + route-scoped allow + consumer in allow → authenticated
|
||||||
|
// path through the case-3 branch (main.go:354-362 success). Verifies
|
||||||
|
// X-Mse-Consumer is still injected when auth is enabled per-route only.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthFalse_RouteAllow_ConsumerAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(routeRuleConfig(t, false, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"x-api-key", "token1"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
require.True(t, test.HasHeaderWithValue(host.GetRequestHeaders(), "X-Mse-Consumer", "consumer1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// global_auth=false + route-scoped allow + consumer not in allow → 403 via
|
||||||
|
// deniedUnauthorizedConsumer (main.go:354-359 reject). Mirror of the
|
||||||
|
// global_auth=true rejection but exercises the case-3 entry condition
|
||||||
|
// `(globalAuthSetFalse || (globalAuthNoSet && ruleSet)) && !noAllow`.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthFalse_RouteAllow_ConsumerNotAllowed(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(routeRuleConfig(t, false, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
require.NoError(t, host.SetRouteName("route-a"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"x-api-key", "token2"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(http.StatusForbidden), resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// global_auth unset + at least one route configured + current route NOT
|
||||||
|
// configured → noAllow short-circuit through `(globalAuthNoSet && ruleSet)`
|
||||||
|
// at main.go:288-293. Existing tests use global_auth=false for this branch;
|
||||||
|
// this drives the unset path so the boolean expression's other operand is
|
||||||
|
// covered.
|
||||||
|
func TestOnHttpRequestHeaders_GlobalAuthUnset_RuleSet_OtherRoute_PassThrough(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
// global_auth omitted; _rules_ entry on route-a only.
|
||||||
|
host, status := test.NewTestHost(routeRuleConfig(t, nil, []string{"consumer1"}))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
// Drive a request on a different route — current rule context has no
|
||||||
|
// allow list, so auth must be skipped entirely.
|
||||||
|
require.NoError(t, host.SetRouteName("route-b"))
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — parse-time edge rejects =================================
|
||||||
|
//
|
||||||
|
// parseGlobalConfig is 93.2% and parseOverrideRuleConfig is 90.9%. The
|
||||||
|
// missing branches are the empty-string credential rejects (singular and
|
||||||
|
// inside the credentials array) and the "allow key absent entirely" branch
|
||||||
|
// at parseOverrideRuleConfig:251 which existing tests don't reach because
|
||||||
|
// every route-rule fixture supplies `allow` (possibly empty).
|
||||||
|
|
||||||
|
// credential: "" must be rejected at parseGlobalConfig:206-208 — distinct
|
||||||
|
// from "credential field absent" already covered by mixed-credential
|
||||||
|
// fixtures in main_test.go.
|
||||||
|
func TestParseGlobalConfig_EmptyCredentialString(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credential": ""},
|
||||||
|
},
|
||||||
|
"keys": []string{"x-api-key"},
|
||||||
|
"in_header": true,
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty string inside the credentials array must be rejected at
|
||||||
|
// parseGlobalConfig:215-217 — separate branch from the "credentials array
|
||||||
|
// empty" reject already covered by invalidEmptyPluralCredentialsConfig.
|
||||||
|
func TestParseGlobalConfig_EmptyCredentialInArray(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credentials": []string{"token1", ""}},
|
||||||
|
},
|
||||||
|
"keys": []string{"x-api-key"},
|
||||||
|
"in_header": true,
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// _rules_ entry without an `allow` key at all — distinct from
|
||||||
|
// invalidRuleConfig which supplies `allow: []`. Hits the
|
||||||
|
// `if !allow.Exists()` branch at parseOverrideRuleConfig:251.
|
||||||
|
func TestParseOverrideRuleConfig_AllowMissing(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"consumers": []map[string]interface{}{
|
||||||
|
{"name": "consumer1", "credential": "token1"},
|
||||||
|
},
|
||||||
|
"keys": []string{"x-api-key"},
|
||||||
|
"in_header": true,
|
||||||
|
"_rules_": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"_match_route_": []string{"route-a"},
|
||||||
|
// no "allow" key at all
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusFailed, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
342
plugins/wasm-go/extensions/model-router/main_extra_test.go
Normal file
342
plugins/wasm-go/extensions/model-router/main_extra_test.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
226
plugins/wasm-go/extensions/request-validation/main_extra_test.go
Normal file
226
plugins/wasm-go/extensions/request-validation/main_extra_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"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 mustConfig(t *testing.T, m map[string]interface{}) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidHeaderSchemaCfg supplies a header_schema whose JSON syntax is valid
|
||||||
|
// (so AddResource at parseConfig time succeeds) but whose schema keywords are
|
||||||
|
// not (so Compile at request time fails). Used to drive the
|
||||||
|
// "compile schema failed" log+continue branch shared by both
|
||||||
|
// onHttpRequestHeaders and onHttpRequestBody.
|
||||||
|
func invalidHeaderSchemaCfg(t *testing.T) json.RawMessage {
|
||||||
|
return mustConfig(t, map[string]interface{}{
|
||||||
|
"header_schema": `{"type": "invalid_type", "properties": {}}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidBodySchemaCfg(t *testing.T) json.RawMessage {
|
||||||
|
return mustConfig(t, map[string]interface{}{
|
||||||
|
"body_schema": `{"type": "invalid_type", "properties": {}}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module A — parseConfig rejected_code edge defaults ==================
|
||||||
|
//
|
||||||
|
// parseConfig is 94.1% in baseline. The `else` branch at main.go:117-119
|
||||||
|
// (defaultRejectedCode = 403) is unreached because every existing fixture
|
||||||
|
// supplies an in-range rejected_code (400 / 422 / 403). The two failure
|
||||||
|
// modes — code unset (== 0) and code out of valid HTTP range — share the
|
||||||
|
// same default, but only the second is a real config error worth pinning;
|
||||||
|
// the first protects users who omit rejected_code entirely.
|
||||||
|
|
||||||
|
func TestParseConfig_RejectedCodeOmitted_DefaultsTo403(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"header_schema": `{"type":"object","required":["x-required"]}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
// rejected_code intentionally omitted — defaultRejectedCode = 403
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, conf)
|
||||||
|
require.Equal(t, uint32(403), conf.(*Config).rejectedCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfig_RejectedCodeOutOfRange_DefaultsTo403(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"header_schema": `{"type":"object","required":["x-required"]}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
"rejected_code": 999, // > 600 → falls to default
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
conf, err := host.GetMatchConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, conf)
|
||||||
|
require.Equal(t, uint32(403), conf.(*Config).rejectedCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module B — onHttpRequestHeaders compile-error pass-through ==========
|
||||||
|
//
|
||||||
|
// The compile-error branch at main.go:152-156 is uncovered: every existing
|
||||||
|
// fixture provides a header_schema that both AddResource and Compile accept.
|
||||||
|
// invalidSchemaConfig in main_test.go uses it only to assert parse success,
|
||||||
|
// not to drive a request through it. Behavior contract: log error and
|
||||||
|
// ActionContinue (fail-open) — distinct from validate-failure which is 4xx.
|
||||||
|
|
||||||
|
func TestOnHttpRequestHeaders_CompileFailure_PassThrough(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(invalidHeaderSchemaCfg(t))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "GET"},
|
||||||
|
{"content-type", "application/json"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module C — onHttpRequestBody parse / compile failure paths ==========
|
||||||
|
//
|
||||||
|
// onHttpRequestBody is 72.7%. Two uncovered branches:
|
||||||
|
// - main.go:174-177 — json.Unmarshal failure (body is not JSON) → continue
|
||||||
|
// - main.go:189-192 — Compile failure (bad schema keywords) → continue
|
||||||
|
// Both are fail-open (request not blocked) — encoding the contract that a
|
||||||
|
// malformed payload or operator schema bug must not turn into a 500.
|
||||||
|
|
||||||
|
func TestOnHttpRequestBody_InvalidJson_PassThrough(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
// Body schema fixture — valid schema, then we feed a non-JSON body.
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"body_schema": `{"type":"object","required":["name"]}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
// Headers must run first to advance lifecycle.
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "POST"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
// Body is not JSON — Unmarshal fails and the plugin must
|
||||||
|
// fail-open rather than 4xx (would otherwise turn arbitrary
|
||||||
|
// upstream content into a hard reject).
|
||||||
|
action = host.CallOnHttpRequestBody([]byte("not a json {{{"))
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnHttpRequestBody_CompileFailure_PassThrough(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(invalidBodySchemaCfg(t))
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "POST"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
action = host.CallOnHttpRequestBody([]byte(`{"name":"ok"}`))
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Module D — both-schema interaction ==================================
|
||||||
|
//
|
||||||
|
// Coverage for `bothValidationConfig` only exercises parseConfig; nothing
|
||||||
|
// drives a request through both header + body schema in sequence. Pin the
|
||||||
|
// happy interaction so future refactors don't accidentally short-circuit
|
||||||
|
// body validation when header validation passes.
|
||||||
|
|
||||||
|
func TestOnHttpRequestBody_AfterHeaderValidationPass_BodyValidates(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
cfg := mustConfig(t, map[string]interface{}{
|
||||||
|
"header_schema": `{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"content-type":{"type":"string"}},
|
||||||
|
"required":["content-type"]
|
||||||
|
}`,
|
||||||
|
"body_schema": `{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"id":{"type":"integer"}},
|
||||||
|
"required":["id"]
|
||||||
|
}`,
|
||||||
|
"enable_oas3": true,
|
||||||
|
"rejected_code": 400,
|
||||||
|
"rejected_msg": "validation failed",
|
||||||
|
})
|
||||||
|
host, status := test.NewTestHost(cfg)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
// Headers pass.
|
||||||
|
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/api/test"},
|
||||||
|
{":method", "POST"},
|
||||||
|
{"content-type", "application/json"},
|
||||||
|
})
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
require.Nil(t, host.GetLocalResponse())
|
||||||
|
|
||||||
|
// Body fails schema (missing required `id`).
|
||||||
|
action = host.CallOnHttpRequestBody([]byte(`{"name":"x"}`))
|
||||||
|
require.Equal(t, types.ActionPause, action)
|
||||||
|
|
||||||
|
resp := host.GetLocalResponse()
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, uint32(400), resp.StatusCode)
|
||||||
|
require.Equal(t, "validation failed", string(resp.Data))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user