From 547b7bf45a19560052b4fe9ceb8c2548b136fd2f Mon Sep 17 00:00:00 2001 From: Jingze <52855280+Jing-ze@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:29:51 +0800 Subject: [PATCH] test(transformer): add end-to-end coverage and fix silent error wrapping (#3873) Signed-off-by: jingze --- plugins/wasm-go/extensions/transformer/go.mod | 5 +- plugins/wasm-go/extensions/transformer/go.sum | 12 +- .../wasm-go/extensions/transformer/main.go | 13 +- .../extensions/transformer/main_test.go | 1023 +++++++++++++++++ 4 files changed, 1037 insertions(+), 16 deletions(-) create mode 100644 plugins/wasm-go/extensions/transformer/main_test.go diff --git a/plugins/wasm-go/extensions/transformer/go.mod b/plugins/wasm-go/extensions/transformer/go.mod index 99c9bfa1c..4493c3901 100644 --- a/plugins/wasm-go/extensions/transformer/go.mod +++ b/plugins/wasm-go/extensions/transformer/go.mod @@ -5,8 +5,8 @@ go 1.24.1 toolchain go1.24.4 require ( - github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 - github.com/higress-group/wasm-go v1.0.2 + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2 + github.com/higress-group/wasm-go v1.0.10-0.20260120033417-1c84f010156d github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.18.0 @@ -18,6 +18,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.7.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/resp v0.1.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/plugins/wasm-go/extensions/transformer/go.sum b/plugins/wasm-go/extensions/transformer/go.sum index d2a0be355..fb5645ca1 100644 --- a/plugins/wasm-go/extensions/transformer/go.sum +++ b/plugins/wasm-go/extensions/transformer/go.sum @@ -2,18 +2,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80 h1:xqmtTZI0JQ2O+Lg9/CE6c+Tw9KD6FnvWw8EpLVuuvfg= -github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250611100342-5654e89a7a80/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA= -github.com/higress-group/wasm-go v1.0.0 h1:4Ik5n3FsJ5+r13KLQl2ky+8NuAE8dfWQwoKxXYD2KAw= -github.com/higress-group/wasm-go v1.0.0/go.mod h1:ODBV27sjmhIW8Cqv3R74EUcTnbdkE69bmXBQFuRkY1M= -github.com/higress-group/wasm-go v1.0.2 h1:8fQqR+wHts8tP+v7GYxmsCNyW5nAjn9wPYV0/+Seqzg= -github.com/higress-group/wasm-go v1.0.2/go.mod h1:882/J8ccU4i+LeyFKmeicbHWAYLj8y7YZr60zk0OOCI= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2 h1:NY33OrWCJJ+DFiLc+lsBY4Ywor2Ik61ssk6qkGF8Ypo= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20251103120604-77e9cce339d2/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA= +github.com/higress-group/wasm-go v1.0.10-0.20260120033417-1c84f010156d h1:LgYbzEBtg0+LEqoebQeMVgAB6H5SgqG+KN+gBhNfKbM= +github.com/higress-group/wasm-go v1.0.10-0.20260120033417-1c84f010156d/go.mod h1:uKVYICbRaxTlKqdm8E0dpjbysxM8uCPb9LV26hF3Km8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wazero v1.7.2 h1:1+z5nXJNwMLPAWaTePFi49SSTL0IMx/i3Fg8Yc25GDc= +github.com/tetratelabs/wazero v1.7.2/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/plugins/wasm-go/extensions/transformer/main.go b/plugins/wasm-go/extensions/transformer/main.go index 1822ca83c..1263ee812 100644 --- a/plugins/wasm-go/extensions/transformer/main.go +++ b/plugins/wasm-go/extensions/transformer/main.go @@ -709,8 +709,7 @@ func newTransformRule(rules []gjson.Result) (res []TransformRule, err error) { var tRule TransformRule tRule.operate = strings.ToLower(r.Get("operate").String()) if !isValidOperation(tRule.operate) { - errors.Wrapf(err, "invalid operate type %q", tRule.operate) - return + return nil, errors.Errorf("invalid operate type %q", tRule.operate) } if tRule.operate == "map" { @@ -720,8 +719,7 @@ func newTransformRule(rules []gjson.Result) (res []TransformRule, err error) { } else { tRule.mapSource = mapSourceInJson.String() if !isValidMapSource(tRule.mapSource) { - errors.Wrapf(err, "invalid map source %q", tRule.mapSource) - return + return nil, errors.Errorf("invalid map source %q", tRule.mapSource) } } } @@ -738,8 +736,7 @@ func newTransformRule(rules []gjson.Result) (res []TransformRule, err error) { valueType = "string" } if !isValidJsonType(valueType) { - errors.Wrapf(err, "invalid body params type %q", valueType) - return + return nil, errors.Errorf("invalid body params type %q", valueType) } tRule.body = append(tRule.body, constructParam(b, tRule.operate, valueType)) } @@ -1110,7 +1107,7 @@ func (h jsonHandler) handle(host, path string, oriData []byte, mapSourceData map } convertedAppendValue, err := convertByJsonType(valueType, appendValue) if err != nil { - return nil, errors.Wrapf(err, errAppend.Error()) + return nil, errors.Wrap(err, errAppend.Error()) } oldValue := gjson.GetBytes(data, key) if !oldValue.Exists() { @@ -1332,7 +1329,7 @@ func newKvtGroup(rules []TransformRule, typ string) (g []kvtOperation, isChange case "append": kvtOp.kvtOpType = AppendK default: - return nil, false, false, errors.Wrap(err, "invalid operation type") + return nil, false, false, errors.Errorf("invalid operation type %q", r.operate) } for _, p := range prams { switch r.operate { diff --git a/plugins/wasm-go/extensions/transformer/main_test.go b/plugins/wasm-go/extensions/transformer/main_test.go new file mode 100644 index 000000000..3660e3c37 --- /dev/null +++ b/plugins/wasm-go/extensions/transformer/main_test.go @@ -0,0 +1,1023 @@ +// 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() + }) +} + +// --- 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) + }) +}