diff --git a/plugins/wasm-go/extensions/ai-statistics/main_extra_test.go b/plugins/wasm-go/extensions/ai-statistics/main_extra_test.go new file mode 100644 index 000000000..76be69074 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-statistics/main_extra_test.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/basic-auth/main_extra_test.go b/plugins/wasm-go/extensions/basic-auth/main_extra_test.go new file mode 100644 index 000000000..fbbe23876 --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/main_extra_test.go @@ -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) + }) +} diff --git a/plugins/wasm-go/extensions/bot-detect/config/bot_detect_config_extra_test.go b/plugins/wasm-go/extensions/bot-detect/config/bot_detect_config_extra_test.go new file mode 100644 index 000000000..6619b3902 --- /dev/null +++ b/plugins/wasm-go/extensions/bot-detect/config/bot_detect_config_extra_test.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/custom-response/main_extra_test.go b/plugins/wasm-go/extensions/custom-response/main_extra_test.go new file mode 100644 index 000000000..bbcf3e12d --- /dev/null +++ b/plugins/wasm-go/extensions/custom-response/main_extra_test.go @@ -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()) + }) +} diff --git a/plugins/wasm-go/extensions/ext-auth/config/config_extra_test.go b/plugins/wasm-go/extensions/ext-auth/config/config_extra_test.go new file mode 100644 index 000000000..35a9da9a2 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/config/config_extra_test.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/ext-auth/expr/expr_extra_test.go b/plugins/wasm-go/extensions/ext-auth/expr/expr_extra_test.go new file mode 100644 index 000000000..db25f0be7 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/expr/expr_extra_test.go @@ -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")) +} diff --git a/plugins/wasm-go/extensions/ext-auth/util/utils_test.go b/plugins/wasm-go/extensions/ext-auth/util/utils_test.go new file mode 100644 index 000000000..d93e4bb57 --- /dev/null +++ b/plugins/wasm-go/extensions/ext-auth/util/utils_test.go @@ -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")) +} diff --git a/plugins/wasm-go/extensions/ip-restriction/main_extra_test.go b/plugins/wasm-go/extensions/ip-restriction/main_extra_test.go new file mode 100644 index 000000000..38f83f4e5 --- /dev/null +++ b/plugins/wasm-go/extensions/ip-restriction/main_extra_test.go @@ -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." 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"]) + }) +} diff --git a/plugins/wasm-go/extensions/key-auth/main_extra_test.go b/plugins/wasm-go/extensions/key-auth/main_extra_test.go new file mode 100644 index 000000000..d594fa355 --- /dev/null +++ b/plugins/wasm-go/extensions/key-auth/main_extra_test.go @@ -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) + }) +} diff --git a/plugins/wasm-go/extensions/model-router/main_extra_test.go b/plugins/wasm-go/extensions/model-router/main_extra_test.go new file mode 100644 index 000000000..32d56ee41 --- /dev/null +++ b/plugins/wasm-go/extensions/model-router/main_extra_test.go @@ -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) + }) +} diff --git a/plugins/wasm-go/extensions/request-validation/main_extra_test.go b/plugins/wasm-go/extensions/request-validation/main_extra_test.go new file mode 100644 index 000000000..4e91959bd --- /dev/null +++ b/plugins/wasm-go/extensions/request-validation/main_extra_test.go @@ -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)) + }) +}