Files
higress/plugins/wasm-go/extensions/custom-response/main_extra_test.go

247 lines
8.7 KiB
Go

// Copyright (c) 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"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())
})
}