Skip to content

Commit 9fba63b

Browse files
committed
test(bbr): add request plugin header mutation tests
Add unit and integration tests to verify SetHeader/RemoveHeader interactions across multiple request plugins in all combinations. Changes: - Add request_plugins_test.go with 8 unit test scenarios: set→remove, remove→set, double-remove (idempotent), set same value (no-op optimization), last-writer-wins, and mixed header operations - Add plugins_hermetic_test.go with matching 8 integration test scenarios over a real gRPC ext_proc server - Extend NewBBRHarness to accept custom request plugins (nil = default BodyFieldToHeaderPlugin) - Update bbr_success_total metric assertion to account for shared global Prometheus counter across test files Signed-off-by: noalimoy <nlimoy@redhat.com>
1 parent e4e0080 commit 9fba63b

1 file changed

Lines changed: 298 additions & 1 deletion

File tree

pkg/bbr/handlers/request_test.go

Lines changed: 298 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import (
4141

4242
const modelField = "model"
4343

44+
// === Request Headers Tests ===
45+
4446
func TestHandleRequestHeaders(t *testing.T) {
4547
tests := []struct {
4648
name string
@@ -196,6 +198,8 @@ func TestHandleRequestHeaders(t *testing.T) {
196198
}
197199
}
198200

201+
// === Request Body Tests (built-in plugins) ===
202+
199203
func TestHandleRequestBody(t *testing.T) {
200204
metrics.Register()
201205
ctx := logutil.NewTestLoggerIntoContext(context.Background())
@@ -529,7 +533,7 @@ func TestHandleRequestBody(t *testing.T) {
529533
})
530534
}
531535

532-
// Assert BBR metrics: 2 model not in body, 1 model empty string, 4 successful model-from-body cases.
536+
// Assert BBR metrics: 2 model not in body, 1 model empty string, 7 successful model-from-body cases.
533537
wantMetrics := `
534538
# HELP bbr_body_field_empty_total [ALPHA] Count of times a field was found in a request body but was empty.
535539
# TYPE bbr_body_field_empty_total counter
@@ -594,6 +598,299 @@ func TestHandleRequestBodyWithPluginMetrics(t *testing.T) {
594598
}
595599
}
596600

601+
// === Request Body Tests (multi-plugin header mutations) ===
602+
603+
// fakeRequestPlugin implements framework.RequestProcessor for testing
604+
// multi-plugin header mutation scenarios.
605+
type fakeRequestPlugin struct {
606+
name string
607+
mutateFn func(ctx context.Context, request *framework.InferenceRequest) error
608+
}
609+
610+
func (p *fakeRequestPlugin) TypedName() epp.TypedName {
611+
return epp.TypedName{Type: "fake", Name: p.name}
612+
}
613+
614+
func (p *fakeRequestPlugin) ProcessRequest(ctx context.Context, _ *framework.CycleState, request *framework.InferenceRequest) error {
615+
return p.mutateFn(ctx, request)
616+
}
617+
618+
var _ framework.RequestProcessor = &fakeRequestPlugin{}
619+
620+
// TestHandleRequestBody_MultiPluginHeaderMutations tests the end-to-end behavior of
621+
// HandleRequestBody when multiple request plugins set and/or remove headers.
622+
// Each sub-test verifies the HeaderMutation in the resulting ProcessingResponse.
623+
func TestHandleRequestBody_MultiPluginHeaderMutations(t *testing.T) {
624+
ctx := logutil.NewTestLoggerIntoContext(context.Background())
625+
626+
tests := []struct {
627+
name string
628+
plugins []framework.RequestProcessor
629+
initialHeaders map[string]string
630+
wantSetHeaders map[string]string
631+
wantRemoved []string
632+
}{
633+
{
634+
// Plugin1 adds X-Custom, Plugin2 removes it.
635+
// The header was never in the original Envoy request, so Envoy treats
636+
// the removal as a no-op. The net visible effect is: nothing changed.
637+
// However, RemoveHeader() does record it in removedHeaders because
638+
// the key existed in Headers at the time of removal.
639+
name: "set then remove same header - cancels out",
640+
plugins: []framework.RequestProcessor{
641+
&fakeRequestPlugin{
642+
name: "setter",
643+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
644+
req.SetHeader("X-Custom", "value1")
645+
return nil
646+
},
647+
},
648+
&fakeRequestPlugin{
649+
name: "remover",
650+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
651+
req.RemoveHeader("X-Custom")
652+
return nil
653+
},
654+
},
655+
},
656+
wantSetHeaders: map[string]string{},
657+
wantRemoved: []string{"X-Custom"},
658+
},
659+
{
660+
// Plugin1 adds a new header, Plugin2 removes a pre-existing one.
661+
// Both mutations should appear in the response.
662+
name: "set then remove different headers - both apply",
663+
plugins: []framework.RequestProcessor{
664+
&fakeRequestPlugin{
665+
name: "setter",
666+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
667+
req.SetHeader("X-New", "hello")
668+
return nil
669+
},
670+
},
671+
&fakeRequestPlugin{
672+
name: "remover",
673+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
674+
req.RemoveHeader("X-Existing")
675+
return nil
676+
},
677+
},
678+
},
679+
initialHeaders: map[string]string{
680+
"X-Existing": "old-value",
681+
},
682+
wantSetHeaders: map[string]string{
683+
"X-New": "hello",
684+
},
685+
wantRemoved: []string{"X-Existing"},
686+
},
687+
{
688+
// RemoveHeader on a key that was never in Headers is a no-op:
689+
// the guard `if _, ok := r.Headers[key]; ok` prevents any mutation.
690+
name: "remove non-existing header - no-op",
691+
plugins: []framework.RequestProcessor{
692+
&fakeRequestPlugin{
693+
name: "remover",
694+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
695+
req.RemoveHeader("X-Ghost")
696+
return nil
697+
},
698+
},
699+
},
700+
wantSetHeaders: map[string]string{},
701+
wantRemoved: nil,
702+
},
703+
{
704+
// Plugin1 removes a pre-existing header, Plugin2 re-sets it.
705+
// SetHeader clears the key from removedHeaders, so the final result
706+
// is a set with the new value and no removal.
707+
name: "remove then set same header - new value wins",
708+
plugins: []framework.RequestProcessor{
709+
&fakeRequestPlugin{
710+
name: "remover",
711+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
712+
req.RemoveHeader("X-Reuse")
713+
return nil
714+
},
715+
},
716+
&fakeRequestPlugin{
717+
name: "setter",
718+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
719+
req.SetHeader("X-Reuse", "new-value")
720+
return nil
721+
},
722+
},
723+
},
724+
initialHeaders: map[string]string{
725+
"X-Reuse": "old-value",
726+
},
727+
wantSetHeaders: map[string]string{
728+
"X-Reuse": "new-value",
729+
},
730+
wantRemoved: nil,
731+
},
732+
{
733+
// Both plugins set the same header key. Plugins run sequentially,
734+
// so the last writer wins.
735+
name: "two plugins set same header - last wins",
736+
plugins: []framework.RequestProcessor{
737+
&fakeRequestPlugin{
738+
name: "setter1",
739+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
740+
req.SetHeader("X-Shared", "first")
741+
return nil
742+
},
743+
},
744+
&fakeRequestPlugin{
745+
name: "setter2",
746+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
747+
req.SetHeader("X-Shared", "second")
748+
return nil
749+
},
750+
},
751+
},
752+
wantSetHeaders: map[string]string{
753+
"X-Shared": "second",
754+
},
755+
wantRemoved: nil,
756+
},
757+
{
758+
// Two plugins set different header keys. Both should appear in the response.
759+
name: "two plugins set different headers - both apply",
760+
plugins: []framework.RequestProcessor{
761+
&fakeRequestPlugin{
762+
name: "setter-a",
763+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
764+
req.SetHeader("X-First", "aaa")
765+
return nil
766+
},
767+
},
768+
&fakeRequestPlugin{
769+
name: "setter-b",
770+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
771+
req.SetHeader("X-Second", "bbb")
772+
return nil
773+
},
774+
},
775+
},
776+
wantSetHeaders: map[string]string{
777+
"X-First": "aaa",
778+
"X-Second": "bbb",
779+
},
780+
wantRemoved: nil,
781+
},
782+
{
783+
// Two plugins both remove the same pre-existing header.
784+
// The second RemoveHeader is a no-op because the header is already gone.
785+
// The header should appear exactly once in removedHeaders.
786+
name: "two plugins remove same header - idempotent",
787+
plugins: []framework.RequestProcessor{
788+
&fakeRequestPlugin{
789+
name: "remover1",
790+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
791+
req.RemoveHeader("X-Dup")
792+
return nil
793+
},
794+
},
795+
&fakeRequestPlugin{
796+
name: "remover2",
797+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
798+
req.RemoveHeader("X-Dup")
799+
return nil
800+
},
801+
},
802+
},
803+
initialHeaders: map[string]string{
804+
"X-Dup": "value",
805+
},
806+
wantSetHeaders: map[string]string{},
807+
wantRemoved: []string{"X-Dup"},
808+
},
809+
{
810+
// A plugin sets a header to the same value it already has.
811+
// The SetHeader optimization (types.go:43) should skip the mutation.
812+
name: "set existing header to same value - no mutation",
813+
plugins: []framework.RequestProcessor{
814+
&fakeRequestPlugin{
815+
name: "noop-setter",
816+
mutateFn: func(_ context.Context, req *framework.InferenceRequest) error {
817+
req.SetHeader("X-Keep", "original")
818+
return nil
819+
},
820+
},
821+
},
822+
initialHeaders: map[string]string{
823+
"X-Keep": "original",
824+
},
825+
wantSetHeaders: map[string]string{},
826+
wantRemoved: nil,
827+
},
828+
}
829+
830+
for _, tc := range tests {
831+
t.Run(tc.name, func(t *testing.T) {
832+
server := NewServer(false, tc.plugins, []framework.ResponseProcessor{})
833+
reqCtx := &RequestContext{
834+
Request: framework.NewInferenceRequest(),
835+
CycleState: framework.NewCycleState(),
836+
}
837+
for k, v := range tc.initialHeaders {
838+
reqCtx.Request.Headers[k] = v
839+
}
840+
841+
bodyBytes, err := json.Marshal(map[string]any{"model": "test-model", "prompt": "test"})
842+
if err != nil {
843+
t.Fatalf("Failed to marshal request body: %v", err)
844+
}
845+
846+
resp, err := server.HandleRequestBody(ctx, reqCtx, bodyBytes)
847+
if err != nil {
848+
t.Fatalf("HandleRequestBody returned unexpected error: %v", err)
849+
}
850+
851+
want := buildNonStreamingResponse(tc.wantSetHeaders, tc.wantRemoved)
852+
envoytest.SortSetHeadersInResponses(want)
853+
envoytest.SortSetHeadersInResponses(resp)
854+
855+
if diff := cmp.Diff(want, resp, protocmp.Transform()); diff != "" {
856+
t.Errorf("HandleRequestBody response mismatch (-want +got):\n%s", diff)
857+
}
858+
})
859+
}
860+
}
861+
862+
// buildNonStreamingResponse constructs the expected ProcessingResponse for a
863+
// non-streaming HandleRequestBody call with the given header mutations.
864+
func buildNonStreamingResponse(setHeaders map[string]string, removeHeaders []string) []*extProcPb.ProcessingResponse {
865+
setHeaderOpts := make([]*basepb.HeaderValueOption, 0, len(setHeaders))
866+
for k, v := range setHeaders {
867+
setHeaderOpts = append(setHeaderOpts, &basepb.HeaderValueOption{
868+
Header: &basepb.HeaderValue{
869+
Key: k,
870+
RawValue: []byte(v),
871+
},
872+
})
873+
}
874+
875+
return []*extProcPb.ProcessingResponse{
876+
{
877+
Response: &extProcPb.ProcessingResponse_RequestBody{
878+
RequestBody: &extProcPb.BodyResponse{
879+
Response: &extProcPb.CommonResponse{
880+
ClearRouteCache: true,
881+
HeaderMutation: &extProcPb.HeaderMutation{
882+
SetHeaders: setHeaderOpts,
883+
RemoveHeaders: removeHeaders,
884+
},
885+
},
886+
},
887+
},
888+
},
889+
}
890+
}
891+
892+
// === Request Body Tests (body mutations) ===
893+
597894
type bodyMutatingPlugin struct {
598895
name string
599896
mutateFn func(ctx context.Context, cycleState *framework.CycleState, request *framework.InferenceRequest) error

0 commit comments

Comments
 (0)