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