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:
Jingze
2026-06-15 20:33:10 +08:00
committed by GitHub
parent 3065d4e071
commit 45fc5a31bc
11 changed files with 2270 additions and 0 deletions

View 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)
}

View 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)
})
}

View File

@@ -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)
}

View 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())
})
}

View 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)
}

View 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"))
}

View 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"))
}

View 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"])
})
}

View 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)
})
}

View 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)
})
}

View 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))
})
}