diff --git a/go.mod b/go.mod index 23eebfc..8ea77a8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.24.3 require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 - github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-immutable-radix/v2 v2.1.0 github.com/kedacore/keda/v2 v2.17.1 github.com/kelseyhightower/envconfig v1.4.0 @@ -61,6 +60,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kylelemons/godebug v1.1.0 // indirect diff --git a/interceptor/middleware/mock_conn_test.go b/interceptor/middleware/mock_conn_test.go new file mode 100644 index 0000000..3a6682f --- /dev/null +++ b/interceptor/middleware/mock_conn_test.go @@ -0,0 +1,173 @@ +// /* +// Copyright 2023 The KEDA Authors. +// +// 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. +// */ +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: net (interfaces: Conn) +// +// Generated by this command: +// +// mockgen -copyright_file=hack/boilerplate.go.txt -destination=interceptor/middleware/mock_conn_test.go -package=middleware net Conn +// + +// Package middleware is a generated GoMock package. +package middleware + +import ( + net "net" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockConn is a mock of Conn interface. +type MockConn struct { + ctrl *gomock.Controller + recorder *MockConnMockRecorder + isgomock struct{} +} + +// MockConnMockRecorder is the mock recorder for MockConn. +type MockConnMockRecorder struct { + mock *MockConn +} + +// NewMockConn creates a new mock instance. +func NewMockConn(ctrl *gomock.Controller) *MockConn { + mock := &MockConn{ctrl: ctrl} + mock.recorder = &MockConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConn) EXPECT() *MockConnMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockConn) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConn)(nil).Close)) +} + +// LocalAddr mocks base method. +func (m *MockConn) LocalAddr() net.Addr { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LocalAddr") + ret0, _ := ret[0].(net.Addr) + return ret0 +} + +// LocalAddr indicates an expected call of LocalAddr. +func (mr *MockConnMockRecorder) LocalAddr() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LocalAddr", reflect.TypeOf((*MockConn)(nil).LocalAddr)) +} + +// Read mocks base method. +func (m *MockConn) Read(b []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", b) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockConnMockRecorder) Read(b any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockConn)(nil).Read), b) +} + +// RemoteAddr mocks base method. +func (m *MockConn) RemoteAddr() net.Addr { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteAddr") + ret0, _ := ret[0].(net.Addr) + return ret0 +} + +// RemoteAddr indicates an expected call of RemoteAddr. +func (mr *MockConnMockRecorder) RemoteAddr() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteAddr", reflect.TypeOf((*MockConn)(nil).RemoteAddr)) +} + +// SetDeadline mocks base method. +func (m *MockConn) SetDeadline(t time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetDeadline", t) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetDeadline indicates an expected call of SetDeadline. +func (mr *MockConnMockRecorder) SetDeadline(t any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDeadline", reflect.TypeOf((*MockConn)(nil).SetDeadline), t) +} + +// SetReadDeadline mocks base method. +func (m *MockConn) SetReadDeadline(t time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetReadDeadline", t) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetReadDeadline indicates an expected call of SetReadDeadline. +func (mr *MockConnMockRecorder) SetReadDeadline(t any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReadDeadline", reflect.TypeOf((*MockConn)(nil).SetReadDeadline), t) +} + +// SetWriteDeadline mocks base method. +func (m *MockConn) SetWriteDeadline(t time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetWriteDeadline", t) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetWriteDeadline indicates an expected call of SetWriteDeadline. +func (mr *MockConnMockRecorder) SetWriteDeadline(t any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWriteDeadline", reflect.TypeOf((*MockConn)(nil).SetWriteDeadline), t) +} + +// Write mocks base method. +func (m *MockConn) Write(b []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", b) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockConnMockRecorder) Write(b any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockConn)(nil).Write), b) +} diff --git a/interceptor/middleware/mock_hijacker_responsewriter_test.go b/interceptor/middleware/mock_hijacker_responsewriter_test.go new file mode 100644 index 0000000..eb67823 --- /dev/null +++ b/interceptor/middleware/mock_hijacker_responsewriter_test.go @@ -0,0 +1,119 @@ +// /* +// Copyright 2023 The KEDA Authors. +// +// 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. +// */ +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: interceptor/middleware/responsewriter_test.go +// +// Generated by this command: +// +// mockgen -copyright_file=hack/boilerplate.go.txt -destination=interceptor/middleware/mock_hijacker_responsewriter_test.go -package=middleware -source=interceptor/middleware/responsewriter_test.go HijackerResponseWriter +// + +// Package middleware is a generated GoMock package. +package middleware + +import ( + bufio "bufio" + net "net" + http "net/http" + reflect "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +// MockHijackerResponseWriter is a mock of HijackerResponseWriter interface. +type MockHijackerResponseWriter struct { + ctrl *gomock.Controller + recorder *MockHijackerResponseWriterMockRecorder + isgomock struct{} +} + +// MockHijackerResponseWriterMockRecorder is the mock recorder for MockHijackerResponseWriter. +type MockHijackerResponseWriterMockRecorder struct { + mock *MockHijackerResponseWriter +} + +// NewMockHijackerResponseWriter creates a new mock instance. +func NewMockHijackerResponseWriter(ctrl *gomock.Controller) *MockHijackerResponseWriter { + mock := &MockHijackerResponseWriter{ctrl: ctrl} + mock.recorder = &MockHijackerResponseWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHijackerResponseWriter) EXPECT() *MockHijackerResponseWriterMockRecorder { + return m.recorder +} + +// Header mocks base method. +func (m *MockHijackerResponseWriter) Header() http.Header { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Header") + ret0, _ := ret[0].(http.Header) + return ret0 +} + +// Header indicates an expected call of Header. +func (mr *MockHijackerResponseWriterMockRecorder) Header() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockHijackerResponseWriter)(nil).Header)) +} + +// Hijack mocks base method. +func (m *MockHijackerResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Hijack") + ret0, _ := ret[0].(net.Conn) + ret1, _ := ret[1].(*bufio.ReadWriter) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Hijack indicates an expected call of Hijack. +func (mr *MockHijackerResponseWriterMockRecorder) Hijack() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hijack", reflect.TypeOf((*MockHijackerResponseWriter)(nil).Hijack)) +} + +// Write mocks base method. +func (m *MockHijackerResponseWriter) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockHijackerResponseWriterMockRecorder) Write(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockHijackerResponseWriter)(nil).Write), arg0) +} + +// WriteHeader mocks base method. +func (m *MockHijackerResponseWriter) WriteHeader(statusCode int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "WriteHeader", statusCode) +} + +// WriteHeader indicates an expected call of WriteHeader. +func (mr *MockHijackerResponseWriterMockRecorder) WriteHeader(statusCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteHeader", reflect.TypeOf((*MockHijackerResponseWriter)(nil).WriteHeader), statusCode) +} diff --git a/interceptor/middleware/responsewriter.go b/interceptor/middleware/responsewriter.go index bf11c53..1dab815 100644 --- a/interceptor/middleware/responsewriter.go +++ b/interceptor/middleware/responsewriter.go @@ -28,6 +28,7 @@ func (rw *responseWriter) StatusCode() int { } var _ http.ResponseWriter = (*responseWriter)(nil) +var _ http.Hijacker = (*responseWriter)(nil) func (rw *responseWriter) Header() http.Header { return rw.downstreamResponseWriter.Header() diff --git a/interceptor/middleware/responsewriter_test.go b/interceptor/middleware/responsewriter_test.go index 860b426..785bf4e 100644 --- a/interceptor/middleware/responsewriter_test.go +++ b/interceptor/middleware/responsewriter_test.go @@ -1,14 +1,37 @@ package middleware import ( + "bufio" + "fmt" "net/http" "net/http/httptest" + "go.uber.org/mock/gomock" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// HijackerResponseWriter combines http.ResponseWriter and http.Hijacker interfaces +// This is used for generating mocks that implement both interfaces +type HijackerResponseWriter interface { + http.ResponseWriter + http.Hijacker +} + var _ = Describe("responseWriter", func() { + Context("Interface compliance", func() { + It("implements http.ResponseWriter interface", func() { + var rw *responseWriter + var _ http.ResponseWriter = rw + }) + + It("implements http.Hijacker interface", func() { + var rw *responseWriter + var _ http.Hijacker = rw + }) + }) + Context("New", func() { It("returns new object with expected field values set", func() { var ( @@ -119,4 +142,72 @@ var _ = Describe("responseWriter", func() { Expect(w.Code).To(Equal(sc)) }) }) + + Context("Hijack", func() { + var ctrl *gomock.Controller + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + }) + + AfterEach(func() { + ctrl.Finish() + }) + + It("successfully hijacks when downstream ResponseWriter implements http.Hijacker", func() { + // Create mocks using the generated mocks + mockConn := NewMockConn(ctrl) + mockReadWriter := &bufio.ReadWriter{} + mockHijackerWriter := NewMockHijackerResponseWriter(ctrl) + + // Set up expectations + mockHijackerWriter.EXPECT().Hijack().Return(mockConn, mockReadWriter, nil) + + rw := &responseWriter{ + downstreamResponseWriter: mockHijackerWriter, + } + + conn, readWriter, err := rw.Hijack() + + Expect(err).To(BeNil()) + Expect(conn).To(Equal(mockConn)) + Expect(readWriter).To(Equal(mockReadWriter)) + }) + + It("returns error when downstream ResponseWriter does not implement http.Hijacker", func() { + var ( + w = httptest.NewRecorder() + ) + + rw := &responseWriter{ + downstreamResponseWriter: w, + } + + conn, readWriter, err := rw.Hijack() + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(Equal("http.Hijacker not implemented")) + Expect(conn).To(BeNil()) + Expect(readWriter).To(BeNil()) + }) + + It("forwards error when downstream hijacker returns error", func() { + expectedError := fmt.Errorf("hijack failed") + mockHijackerWriter := NewMockHijackerResponseWriter(ctrl) + + // Set up expectations + mockHijackerWriter.EXPECT().Hijack().Return(nil, nil, expectedError) + + rw := &responseWriter{ + downstreamResponseWriter: mockHijackerWriter, + } + + conn, readWriter, err := rw.Hijack() + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(Equal("hijack failed")) + Expect(conn).To(BeNil()) + Expect(readWriter).To(BeNil()) + }) + }) }) diff --git a/tests/checks/interceptor_websocket/interceptor_websocket_test.go b/tests/checks/interceptor_websocket/interceptor_websocket_test.go new file mode 100644 index 0000000..3f540af --- /dev/null +++ b/tests/checks/interceptor_websocket/interceptor_websocket_test.go @@ -0,0 +1,310 @@ +//go:build e2e +// +build e2e + +package interceptor_websocket_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/client-go/kubernetes" + + . "github.com/kedacore/http-add-on/tests/helper" +) + +const ( + testName = "interceptor-websocket-test" +) + +var ( + testNamespace = fmt.Sprintf("%s-ns", testName) + deploymentName = fmt.Sprintf("%s-deployment", testName) + serviceName = fmt.Sprintf("%s-service", testName) + httpScaledObjectName = fmt.Sprintf("%s-http-so", testName) + clientJobName = fmt.Sprintf("%s-client", testName) + host = testName + minReplicaCount = 0 + maxReplicaCount = 2 +) + +type templateData struct { + TestNamespace string + DeploymentName string + ServiceName string + HTTPScaledObjectName string + ClientJobName string + Host string + MinReplicas int + MaxReplicas int +} + +const ( + serviceTemplate = ` +apiVersion: v1 +kind: Service +metadata: + name: {{.ServiceName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: {{.DeploymentName}} +` + + deploymentTemplate = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{.DeploymentName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + replicas: 0 + selector: + matchLabels: + app: {{.DeploymentName}} + template: + metadata: + labels: + app: {{.DeploymentName}} + spec: + containers: + - name: {{.DeploymentName}} + image: ghcr.io/kedacore/tests-websockets:245a788 + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: PORT + value: "8080" + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 +` + + websocketClientJobTemplate = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: {{.ClientJobName}} + namespace: {{.TestNamespace}} +spec: + template: + spec: + containers: + - name: websocket-client + image: ghcr.io/kedacore/tests-websockets:245a788 + command: + - node + - client.js + - ${{.ClientJobName}} + env: + - name: GATEWAY + value: "keda-add-ons-http-interceptor-proxy.keda" + - name: HOST + value: "{{.Host}}" + - name: PORT + value: "8080" + restartPolicy: Never + activeDeadlineSeconds: 300 + backoffLimit: 3 +` + + httpScaledObjectTemplate = ` +kind: HTTPScaledObject +apiVersion: http.keda.sh/v1alpha1 +metadata: + name: {{.HTTPScaledObjectName}} + namespace: {{.TestNamespace}} +spec: + hosts: + - {{.Host}} + targetPendingRequests: 1 + scaledownPeriod: 10 + scaleTargetRef: + name: {{.DeploymentName}} + service: {{.ServiceName}} + port: 8080 + replicas: + min: {{ .MinReplicas }} + max: {{ .MaxReplicas }} +` + + // Simple curl-based WebSocket test using websocat + websocketCurlTestTemplate = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: {{.ClientJobName}}-curl + namespace: {{.TestNamespace}} +spec: + template: + spec: + containers: + - name: curl-test + image: curlimages/curl:latest + command: ["/bin/sh"] + args: + - -c + - | + echo "Testing HTTP connection first..." + curl -H "Host: {{.Host}}" -v http://keda-add-ons-http-interceptor-proxy.keda:8080/ || echo "HTTP test failed" + echo "HTTP test completed" + restartPolicy: Never + activeDeadlineSeconds: 60 + backoffLimit: 1 +` + + // WebSocket test using websocat (curl-like tool for WebSockets) + websocatTestTemplate = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: {{.ClientJobName}}-ws-curl + namespace: {{.TestNamespace}} +spec: + template: + spec: + containers: + - name: websocat-test + image: ghcr.io/vi/websocat:v1.14.0 + command: ["/bin/sh"] + args: + - -c + - | + echo "Installing websocat..." + + echo "Testing WebSocket connection through interceptor..." + echo "Connecting to ws://keda-add-ons-http-interceptor-proxy.keda:8080/ws with Host: {{.Host}}" + + # Test WebSocket connection with Host header + timeout 30 /usr/local/bin/websocat -H "Host: {{.Host}}" ws://keda-add-ons-http-interceptor-proxy.keda:8080/ws --ping-interval 5 --ping-timeout 10 --text --exit-on-eof <