// Copyright (c) 2022 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" "strings" "testing" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" "github.com/higress-group/wasm-go/pkg/test" "github.com/stretchr/testify/require" ) // headersToMap turns mock host headers (sorted [][2]string) into a name -> values map. func headersToMap(hs [][2]string) map[string][]string { out := map[string][]string{} for _, h := range hs { out[h[0]] = append(out[h[0]], h[1]) } return out } // configJSON marshals a config map into a json.RawMessage. func configJSON(cfg map[string]any) json.RawMessage { b, _ := json.Marshal(cfg) return b } // --- parseConfig --- func TestParseConfig_RequestRulesOnly(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "add", "headers": []map[string]any{{"key": "X-Add", "value": "v"}}, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) cfg, err := host.GetMatchConfig() require.NoError(t, err) tcfg := cfg.(*TransformerConfig) require.True(t, tcfg.reroute, "reroute defaults to true") require.NotNil(t, tcfg.reqTrans) require.Nil(t, tcfg.respTrans) }) } func TestParseConfig_RerouteFalse(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reroute": false, "reqRules": []map[string]any{{ "operate": "remove", "headers": []map[string]any{{"key": "x-y"}}, }}, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) cfg, err := host.GetMatchConfig() require.NoError(t, err) require.False(t, cfg.(*TransformerConfig).reroute) }) } func TestParseConfig_NoRulesRejected(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{})) defer host.Reset() require.Equal(t, types.OnPluginStartStatusFailed, status) }) } // --- Request × headers --- func TestRequest_Headers_RemoveAndAddAndRename(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "remove", "headers": []map[string]any{{"key": "X-Drop"}}}, {"operate": "add", "headers": []map[string]any{{"key": "X-New", "value": "v1"}}}, {"operate": "rename", "headers": []map[string]any{{"oldKey": "X-Old", "newKey": "X-Renamed"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {":method", "GET"}, {"X-Drop", "gone"}, {"X-Old", "old-value"}, }) require.Equal(t, types.ActionContinue, action) got := headersToMap(host.GetRequestHeaders()) _, hasDrop := got["x-drop"] require.False(t, hasDrop, "X-Drop should be removed") require.Equal(t, []string{"v1"}, got["x-new"]) require.Equal(t, []string{"old-value"}, got["x-renamed"]) _, hasOld := got["x-old"] require.False(t, hasOld, "X-Old should be deleted after rename") host.CompleteHttp() }) } func TestRequest_Headers_AddSkipsWhenPresent(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{{"key": "X-K", "value": "fresh"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"X-K", "existing"}, }) require.Equal(t, types.ActionContinue, action) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"existing"}, got["x-k"], "add must be a no-op when key already exists") host.CompleteHttp() }) } func TestRequest_Headers_ReplaceActsAsAddWhenMissing(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "replace", "headers": []map[string]any{{"key": "X-R", "newValue": "v"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, }) require.Equal(t, types.ActionContinue, action) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"v"}, got["x-r"], "replace acts as add when target missing") host.CompleteHttp() }) } func TestRequest_Headers_AppendExtendsExisting(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "append", "headers": []map[string]any{{"key": "X-Multi", "appendValue": "added"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"X-Multi", "first"}, }) require.Equal(t, types.ActionContinue, action) got := headersToMap(host.GetRequestHeaders()) require.ElementsMatch(t, []string{"first", "added"}, got["x-multi"]) host.CompleteHttp() }) } func TestRequest_Headers_DedupeStrategies(t *testing.T) { tests := []struct { strategy string input []string // for SPLIT_*, input has a single value containing commas. want []string }{ {"", []string{"a", "b", "a", "c"}, []string{"a"}}, // default = RETAIN_FIRST {"RETAIN_FIRST", []string{"a", "b", "a", "c"}, []string{"a"}}, // explicit {"RETAIN_LAST", []string{"a", "b", "a", "c"}, []string{"c"}}, // last only {"RETAIN_UNIQUE", []string{"a", "b", "a", "c"}, []string{"a", "b", "c"}}, // dedup preserving order {"SPLIT_AND_RETAIN_FIRST", []string{"x,y,z"}, []string{"x"}}, // split first value, keep first part {"SPLIT_AND_RETAIN_LAST", []string{"x,y,z"}, []string{"z"}}, // split first value, keep last part } for _, tc := range tests { t.Run(tc.strategy, func(t *testing.T) { test.RunTest(t, func(t *testing.T) { headers := [][2]string{{":authority", "test.com"}, {":path", "/p"}} for _, v := range tc.input { headers = append(headers, [2]string{"X-Dup", v}) } host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "dedupe", "headers": []map[string]any{{"key": "X-Dup", "strategy": tc.strategy}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders(headers)) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, tc.want, got["x-dup"]) host.CompleteHttp() }) }) } } // --- Request × querys --- func TestRequest_Querys_AddRebuildsPath(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "querys": []map[string]any{{"key": "trace", "value": "1"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/api?foo=bar"}, }) require.Equal(t, types.ActionContinue, action) got := headersToMap(host.GetRequestHeaders()) path := got[":path"][0] require.True(t, strings.HasPrefix(path, "/api?"), "path should retain /api?") require.Contains(t, path, "foo=bar") require.Contains(t, path, "trace=1") host.CompleteHttp() }) } func TestRequest_Querys_Remove(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "remove", "querys": []map[string]any{{"key": "secret"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/api?secret=xxx&keep=yes"}, })) path := headersToMap(host.GetRequestHeaders())[":path"][0] require.NotContains(t, path, "secret") require.Contains(t, path, "keep=yes") host.CompleteHttp() }) } // --- Request × body (JSON) --- func TestRequest_BodyJson_AddAndRemove(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "added", "value": "v"}}}, {"operate": "remove", "body": []map[string]any{{"key": "drop"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{"drop":"x","keep":"y"}`))) body := host.GetRequestBody() // pretty.Pretty inserts whitespace, so check JSON content semantically. require.NotContains(t, string(body), `"drop"`, "drop field should be removed") require.Contains(t, string(body), `"keep"`) require.Contains(t, string(body), `"added"`) require.Contains(t, string(body), `"v"`) host.CompleteHttp() }) } func TestRequest_BodyJson_ValueTypeNumber(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{ {"key": "limit", "value": "10", "value_type": "number"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{}`))) body := host.GetRequestBody() // number type writes 10 not "10" require.Contains(t, string(body), `"limit": 10`) require.NotContains(t, string(body), `"limit": "10"`) host.CompleteHttp() }) } func TestRequest_BodyForm_Urlencoded(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "extra", "value": "x"}}}, {"operate": "remove", "body": []map[string]any{{"key": "drop"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/x-www-form-urlencoded"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`drop=1&keep=yes`))) body := string(host.GetRequestBody()) require.NotContains(t, body, "drop=") require.Contains(t, body, "keep=yes") require.Contains(t, body, "extra=x") host.CompleteHttp() }) } func TestRequest_Body_NonStructuredContentTypeSkipped(t *testing.T) { // content-type=text/plain → body phase is skipped (DontReadRequestBody); // header transform still runs in onHttpRequestHeaders. test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{{"key": "X-Tag", "value": "y"}}}, {"operate": "add", "body": []map[string]any{{"key": "should-not-apply", "value": "v"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "text/plain"}, })) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"y"}, got["x-tag"], "header transform still applies for unsupported content-type") host.CompleteHttp() }) } // --- mapSource = body: header transform delayed to body phase --- func TestRequest_MapFromBody_DelaysHeaderTransform(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "body", "headers": []map[string]any{ {"fromKey": "user.id", "toKey": "X-User-Id"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) // In headers phase, we expect Pause (HeaderStopIteration) because the rule needs body data. action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {":method", "POST"}, {"content-type", "application/json"}, }) require.Equal(t, types.ActionPause, action, "mapSource=body should pause header iteration until body arrives") // Body phase: header should now be set from body json field. require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{"user":{"id":"alice"}}`))) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"alice"}, got["x-user-id"]) host.CompleteHttp() }) } func TestRequest_MapFromBody_NoRequestBodyDoesNotPause(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "body", "headers": []map[string]any{ {"fromKey": "user.id", "toKey": "X-User-Id"}, }, }, { "operate": "add", "headers": []map[string]any{ {"key": "X-Static", "value": "ok"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) action := host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {":method", "POST"}, {"content-type", "application/json"}, }, test.WithEndOfStream(true)) require.Equal(t, types.ActionContinue, action, "request without body must not wait for body callback") got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"ok"}, got["x-static"]) require.NotContains(t, got, "x-user-id") host.CompleteHttp() }) } // --- regex template --- func TestRequest_Headers_AddWithHostPattern(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{ {"key": "X-From-Host", "value": "from-$1", "host_pattern": `^(.+)\.example\.com$`}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "shop.example.com"}, {":path", "/p"}, })) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"from-shop"}, got["x-from-host"]) host.CompleteHttp() }) } func TestRequest_Headers_AddWithPathPattern_NoMatchKeepsValue(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{ {"key": "X-V", "value": "literal", "path_pattern": `^/api/v(\d+)/`}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) // path does NOT match the pattern → value is the literal "literal" require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/static/css"}, })) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"literal"}, got["x-v"]) host.CompleteHttp() }) } // --- Response × headers + body --- func TestResponse_Headers_AddAndRemove(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{{"key": "X-Out", "value": "o"}}}, {"operate": "remove", "headers": []map[string]any{{"key": "Server"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"Server", "envoy"}, {"Content-Type", "text/plain"}, })) got := headersToMap(host.GetResponseHeaders()) require.Equal(t, []string{"o"}, got["x-out"]) _, hasServer := got["server"] require.False(t, hasServer) host.CompleteHttp() }) } func TestResponse_BodyJson_Replace(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "replace", "body": []map[string]any{ {"key": "status", "newValue": "filtered"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseBody([]byte(`{"status":"raw","other":1}`))) body := string(host.GetResponseBody()) require.Contains(t, body, `"status": "filtered"`) require.Contains(t, body, `"other"`) host.CompleteHttp() }) } func TestResponse_NonJsonBodyUnchanged(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "ignored", "value": "x"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"content-type", "text/plain"}, })) // Body callback shouldn't even run with DontReadResponseBody set, but if invoked it must not crash. host.CompleteHttp() }) } // --- request without reqRules: body callback returns Continue without changes --- func TestRequest_NoReqRules_BodyCallbackIsNoop(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{{"key": "X-Marker", "value": "1"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) // Should be Continue and a no-op since reqRules == nil require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{"x":1}`))) host.CompleteHttp() }) } // --- JSON body: full operation matrix --- func TestRequest_BodyJson_Rename(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "rename", "body": []map[string]any{{"oldKey": "old", "newKey": "fresh"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{"old":"v","keep":1}`))) body := string(host.GetRequestBody()) require.NotContains(t, body, `"old"`) require.Contains(t, body, `"fresh"`) require.Contains(t, body, `"keep"`) host.CompleteHttp() }) } func TestRequest_BodyJson_Replace(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "replace", "body": []map[string]any{{"key": "status", "newValue": "filtered"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{"status":"raw"}`))) require.Contains(t, string(host.GetRequestBody()), `"status": "filtered"`) host.CompleteHttp() }) } func TestRequest_BodyJson_AppendNewExistingScalarAndArray(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "append", "body": []map[string]any{ {"key": "fresh", "appendValue": "v1"}, {"key": "scalar", "appendValue": "v2"}, {"key": "arr", "appendValue": "v3"}, {"key": "emptyArr", "appendValue": "v4"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte( `{"scalar":"old","arr":["a","b"],"emptyArr":[]}`))) body := string(host.GetRequestBody()) // branch 1: key not present → becomes scalar v1 require.Contains(t, body, `"fresh": "v1"`) // branch 2: old scalar → becomes [old, v2] require.Contains(t, body, `"scalar": ["old", "v2"]`) // branch 3: existing non-empty array → values appended require.Contains(t, body, `"arr": ["a", "b", "v3"]`) // branch 4: existing empty array → [v4] require.Contains(t, body, `"emptyArr": ["v4"]`) host.CompleteHttp() }) } func TestRequest_BodyJson_DedupeStrategies(t *testing.T) { // JSON dedupe semantics: RETAIN_FIRST/RETAIN_LAST collapse to a scalar (not array); // RETAIN_UNIQUE keeps an array only when more than one unique value remains. cases := []struct { strategy string body string key string contains string }{ {"RETAIN_FIRST", `{"k":["a","b","a"]}`, "k", `"k": "a"`}, {"RETAIN_LAST", `{"k":["a","b","c"]}`, "k", `"k": "c"`}, {"RETAIN_UNIQUE", `{"k":["a","b","a","c"]}`, "k", `"k": ["a", "b", "c"]`}, {"RETAIN_UNIQUE", `{"k":["a","a","a"]}`, "k", `"k": "a"`}, // collapses to scalar when 1 unique } for _, tc := range cases { t.Run(tc.strategy, func(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "dedupe", "body": []map[string]any{{"key": tc.key, "strategy": tc.strategy}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(tc.body))) require.Contains(t, string(host.GetRequestBody()), tc.contains) host.CompleteHttp() }) }) } } func TestRequest_BodyJson_ValueTypeBooleanObject(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{ {"key": "flag", "value": "true", "value_type": "boolean"}, {"key": "meta", "value": `{"a":1}`, "value_type": "object"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{}`))) body := string(host.GetRequestBody()) require.Contains(t, body, `"flag": true`) require.NotContains(t, body, `"flag": "true"`) // object value gets parsed and re-encoded (pretty.Pretty inserts whitespace). require.Contains(t, body, `"meta"`) require.Contains(t, body, `"a": 1`) }) } func TestRequest_BodyJson_NestedPathAndArrayIndex(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{ {"key": "user.profile.name", "value": "alice"}, }}, {"operate": "replace", "body": []map[string]any{ {"key": "items.0", "newValue": "zero"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte( `{"user":{"profile":{}},"items":["a","b"]}`))) body := string(host.GetRequestBody()) require.Contains(t, body, `"name": "alice"`) require.Contains(t, body, `"zero"`) }) } func TestRequest_BodyJson_InvalidJsonReturnsAction(t *testing.T) { // Invalid JSON body — handler returns errors.New("invalid json body"); plugin must still // release the stream (ActionContinue) and not panic. test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "x", "value": "y"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, })) // not valid json — handler returns err, plugin logs warn but does not crash. require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{not-json`))) host.CompleteHttp() }) } // --- mapSource matrix on body --- func TestRequest_MapFromHeaders_ToBody(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "headers", "body": []map[string]any{ {"fromKey": "X-Tenant", "toKey": "tenant"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {"content-type", "application/json"}, {"X-Tenant", "acme"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte(`{}`))) require.Contains(t, string(host.GetRequestBody()), `"tenant"`) require.Contains(t, string(host.GetRequestBody()), `"acme"`) }) } func TestRequest_MapFromQuerys_ToHeader(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "querys", "headers": []map[string]any{ {"fromKey": "trace", "toKey": "X-Trace"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p?trace=t1"}, })) got := headersToMap(host.GetRequestHeaders()) require.Equal(t, []string{"t1"}, got["x-trace"]) }) } // --- response body JSON op matrix --- func TestResponse_BodyJson_AddRemoveAppend(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "added", "value": "v"}}}, {"operate": "remove", "body": []map[string]any{{"key": "drop"}}}, {"operate": "append", "body": []map[string]any{{"key": "tags", "appendValue": "extra"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseBody([]byte( `{"drop":1,"tags":["a"]}`))) body := string(host.GetResponseBody()) require.NotContains(t, body, `"drop"`) require.Contains(t, body, `"added"`) require.Contains(t, body, `"tags": ["a", "extra"]`) }) } // --- multiple rules: order preserved --- func TestRequest_MultipleRules_OrderIsPreserved(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{{"key": "X-Step", "value": "1"}}}, {"operate": "append", "headers": []map[string]any{{"key": "X-Step", "appendValue": "2"}}}, {"operate": "rename", "headers": []map[string]any{{"oldKey": "X-Step", "newKey": "X-Final"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) got := headersToMap(host.GetRequestHeaders()) require.ElementsMatch(t, []string{"1", "2"}, got["x-final"]) _, hasOrig := got["x-step"] require.False(t, hasOrig) }) } // --- config validation regressions (the 4 bugs we fixed) --- func TestParseConfig_InvalidOperateRejected(t *testing.T) { // Regression for bug at main.go ~712: errors.Wrapf(nil,...) returned nil, silently // accepting invalid operate. Must now return a non-nil error and fail start. test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "bogus", "headers": []map[string]any{{"key": "X", "value": "y"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusFailed, status) }) } func TestParseConfig_InvalidMapSourceRejected(t *testing.T) { // Regression for bug at main.go ~724: invalid mapSource silently accepted. test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "bogus", "headers": []map[string]any{{"fromKey": "x", "toKey": "y"}}, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusFailed, status) }) } func TestParseConfig_InvalidValueTypeRejected(t *testing.T) { // Regression for bug at main.go ~742: invalid value_type silently accepted. test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "body": []map[string]any{ {"key": "x", "value": "y", "value_type": "bogus"}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusFailed, status) }) } // --- delayed-header rewrites driven by body data (need_head_trans pathway) --- func TestRequest_MapFromBody_DelaysQueryTransform(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ { "operate": "map", "mapSource": "body", "querys": []map[string]any{ {"fromKey": "tenant", "toKey": "t"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) // Headers phase pauses (need body). require.Equal(t, types.ActionPause, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, {":method", "POST"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestBody([]byte( `{"tenant":"acme"}`))) // Path is rebuilt to include the mapped query param. path := headersToMap(host.GetRequestHeaders())[":path"][0] require.Contains(t, path, "t=acme") }) } func TestResponse_MapFromBody_DelaysHeaderTransform(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ { "operate": "map", "mapSource": "body", "headers": []map[string]any{ {"fromKey": "status", "toKey": "X-Resp-Status"}, }, }, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) // Response headers phase pauses until body arrives. require.Equal(t, types.ActionPause, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"content-type", "application/json"}, })) require.Equal(t, types.ActionContinue, host.CallOnHttpResponseBody([]byte( `{"status":"ok"}`))) got := headersToMap(host.GetResponseHeaders()) require.Equal(t, []string{"ok"}, got["x-resp-status"]) }) } // --- response form-urlencoded body is not a structured-body content type → skipped --- func TestResponse_BodyNonJsonSkipped(t *testing.T) { test.RunTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "respRules": []map[string]any{ {"operate": "add", "body": []map[string]any{{"key": "ignored", "value": "x"}}}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusOK, status) require.Equal(t, types.ActionContinue, host.CallOnHttpRequestHeaders([][2]string{ {":authority", "test.com"}, {":path", "/p"}, })) // form-urlencoded isn't supported for response body → DontReadResponseBody. require.Equal(t, types.ActionContinue, host.CallOnHttpResponseHeaders([][2]string{ {":status", "200"}, {"content-type", "application/x-www-form-urlencoded"}, })) host.CompleteHttp() }) } func TestParseConfig_InvalidRegexRejected(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { host, status := test.NewTestHost(configJSON(map[string]any{ "reqRules": []map[string]any{ {"operate": "add", "headers": []map[string]any{ {"key": "X", "value": "v", "host_pattern": `([`}, }}, }, })) defer host.Reset() require.Equal(t, types.OnPluginStartStatusFailed, status) }) }