Skip to content

Commit 7681a95

Browse files
backend: Add unit tests for serviceproxyhandler and more
Signed-off-by: Murali Annamneni <[email protected]>
1 parent 9da6557 commit 7681a95

File tree

7 files changed

+910
-52
lines changed

7 files changed

+910
-52
lines changed

backend/cmd/headlamp_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ import (
2525
"encoding/json"
2626
"fmt"
2727
"io"
28+
"net"
2829
"net/http"
2930
"net/http/httptest"
3031
"net/url"
3132
"os"
3233
"path/filepath"
3334
"strconv"
35+
"strings"
3436
"testing"
3537
"time"
3638

@@ -43,6 +45,8 @@ import (
4345
"github.com/kubernetes-sigs/headlamp/backend/pkg/telemetry"
4446
"github.com/stretchr/testify/assert"
4547
"github.com/stretchr/testify/require"
48+
corev1 "k8s.io/api/core/v1"
49+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4650
"k8s.io/client-go/tools/clientcmd"
4751
"k8s.io/client-go/tools/clientcmd/api"
4852
)
@@ -1555,3 +1559,123 @@ func TestCacheMiddleware_CacheInvalidation(t *testing.T) {
15551559
assert.Equal(t, "true", resp1.Header.Get("X-HEADLAMP-CACHE"))
15561560
assert.Equal(t, http.StatusOK, resp1.StatusCode)
15571561
}
1562+
1563+
//nolint:funlen
1564+
func TestHandleClusterServiceProxy(t *testing.T) {
1565+
cfg := &HeadlampConfig{
1566+
HeadlampCFG: &headlampconfig.HeadlampCFG{KubeConfigStore: kubeconfig.NewContextStore()},
1567+
telemetryHandler: &telemetry.RequestHandler{},
1568+
telemetryConfig: GetDefaultTestTelemetryConfig(),
1569+
}
1570+
1571+
// Backend service the proxy should call
1572+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1573+
if r.URL.Path == "/healthz" {
1574+
w.WriteHeader(http.StatusOK)
1575+
_, _ = w.Write([]byte("OK"))
1576+
1577+
return
1578+
}
1579+
1580+
http.NotFound(w, r)
1581+
}))
1582+
t.Cleanup(backend.Close)
1583+
1584+
// Extract host:port to feed into the Service external name + port
1585+
bu, err := url.Parse(backend.URL)
1586+
require.NoError(t, err)
1587+
host, portStr, err := net.SplitHostPort(strings.TrimPrefix(bu.Host, "["))
1588+
require.NoError(t, err)
1589+
portNum, err := strconv.Atoi(strings.TrimSuffix(portStr, "]"))
1590+
require.NoError(t, err)
1591+
1592+
// Fake k8s API that returns a Service pointing to backend
1593+
kubeAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1594+
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/default/services/my-service" {
1595+
svc := &corev1.Service{
1596+
ObjectMeta: metav1.ObjectMeta{
1597+
Name: "my-service",
1598+
Namespace: "default",
1599+
},
1600+
Spec: corev1.ServiceSpec{
1601+
ExternalName: host,
1602+
Ports: []corev1.ServicePort{
1603+
{
1604+
Name: "http",
1605+
Port: int32(portNum), //nolint:gosec
1606+
},
1607+
},
1608+
},
1609+
}
1610+
1611+
w.Header().Set("Content-Type", "application/json")
1612+
w.WriteHeader(http.StatusOK)
1613+
_ = json.NewEncoder(w).Encode(svc)
1614+
1615+
return
1616+
}
1617+
1618+
http.NotFound(w, r)
1619+
}))
1620+
t.Cleanup(kubeAPI.Close)
1621+
1622+
// Add a context that matches clusterName in URL
1623+
err = cfg.KubeConfigStore.AddContext(&kubeconfig.Context{
1624+
Name: "kubernetes",
1625+
KubeContext: &api.Context{
1626+
Cluster: "kubernetes",
1627+
AuthInfo: "kubernetes",
1628+
},
1629+
Cluster: &api.Cluster{Server: kubeAPI.URL}, // client-go will talk to this
1630+
AuthInfo: &api.AuthInfo{},
1631+
})
1632+
require.NoError(t, err)
1633+
1634+
router := mux.NewRouter()
1635+
handleClusterServiceProxy(cfg, router)
1636+
1637+
cluster := "kubernetes"
1638+
ns := "default"
1639+
svc := "my-service"
1640+
1641+
// Case 1: Missing ?request => route doesn't match => 404, no headers set
1642+
{
1643+
req := httptest.NewRequest(http.MethodGet,
1644+
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc, nil)
1645+
rr := httptest.NewRecorder()
1646+
router.ServeHTTP(rr, req)
1647+
assert.Equal(t, http.StatusNotFound, rr.Code)
1648+
assert.Empty(t, rr.Header().Get("Cache-Control"))
1649+
}
1650+
1651+
// Case 2: ?request present but missing Authorization => 401, headers set
1652+
{
1653+
req := httptest.NewRequest(http.MethodGet,
1654+
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil)
1655+
rr := httptest.NewRecorder()
1656+
router.ServeHTTP(rr, req)
1657+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
1658+
assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control"))
1659+
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
1660+
assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires"))
1661+
}
1662+
1663+
// Case 3 (Happy path): ?request present and Authorization provided => proxy reaches backend => 200 OK
1664+
{
1665+
req := httptest.NewRequest(http.MethodGet,
1666+
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil)
1667+
req.Header.Set("Authorization", "Bearer test-token")
1668+
1669+
rr := httptest.NewRecorder()
1670+
router.ServeHTTP(rr, req)
1671+
1672+
// Handler always sets no-cache headers
1673+
assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control"))
1674+
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
1675+
assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires"))
1676+
1677+
// Happy path: backend returns OK
1678+
assert.Equal(t, http.StatusOK, rr.Code)
1679+
assert.Equal(t, "OK", rr.Body.String())
1680+
}
1681+
}

backend/pkg/helm/release.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ func (h *Handler) getChart(
582582

583583
// Verify the user has minimal privileges by performing a whoami check.
584584
// This prevents spurious downloads by ensuring basic authentication before proceeding.
585-
func verifyUser(h *Handler, req InstallRequest) bool {
585+
func VerifyUser(h *Handler, req InstallRequest) bool {
586586
restConfig, err := h.Configuration.RESTClientGetter.ToRESTConfig()
587587
if err != nil {
588588
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart")
@@ -619,7 +619,7 @@ func (h *Handler) installRelease(req InstallRequest) {
619619
installClient.CreateNamespace = req.CreateNamespace
620620
installClient.ChartPathOptions.Version = req.Version
621621

622-
if !verifyUser(h, req) {
622+
if !VerifyUser(h, req) {
623623
return
624624
}
625625

backend/pkg/helm/release_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import (
3333
"github.com/stretchr/testify/assert"
3434
"github.com/stretchr/testify/require"
3535
"helm.sh/helm/v3/pkg/action"
36+
"k8s.io/apimachinery/pkg/api/meta"
37+
"k8s.io/cli-runtime/pkg/genericclioptions"
38+
"k8s.io/client-go/discovery"
39+
"k8s.io/client-go/rest"
3640
"k8s.io/client-go/tools/clientcmd"
3741
)
3842

@@ -251,3 +255,65 @@ func TestUninstallRelease(t *testing.T) {
251255

252256
pingStatusTillSuccess(t, "uninstall", "helm-test-asdf", helmHandler.Cache)
253257
}
258+
259+
type staticRESTGetter struct{ cfg *rest.Config }
260+
261+
var _ genericclioptions.RESTClientGetter = (*staticRESTGetter)(nil)
262+
263+
func (s *staticRESTGetter) ToRESTConfig() (*rest.Config, error) {
264+
return s.cfg, nil
265+
}
266+
267+
func (s *staticRESTGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
268+
return nil, nil
269+
}
270+
271+
func (s *staticRESTGetter) ToRESTMapper() (meta.RESTMapper, error) {
272+
return nil, nil
273+
}
274+
275+
func (s *staticRESTGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
276+
return nil
277+
}
278+
279+
func TestVerifyUser(t *testing.T) {
280+
helmHandler := newHelmHandler(t)
281+
282+
tests := []struct {
283+
name string
284+
req helm.InstallRequest
285+
wantResult bool
286+
}{
287+
{
288+
name: "valid user",
289+
req: helm.InstallRequest{
290+
CommonInstallUpdateRequest: helm.CommonInstallUpdateRequest{
291+
Name: "test-release",
292+
},
293+
},
294+
wantResult: true,
295+
},
296+
{
297+
name: "invalid user",
298+
req: helm.InstallRequest{
299+
CommonInstallUpdateRequest: helm.CommonInstallUpdateRequest{
300+
Name: "test-release",
301+
},
302+
},
303+
wantResult: false,
304+
},
305+
}
306+
307+
for _, tt := range tests {
308+
t.Run(tt.name, func(t *testing.T) {
309+
if !tt.wantResult {
310+
helmHandler.Configuration.RESTClientGetter = &staticRESTGetter{
311+
cfg: &rest.Config{Host: ""}, // invalid/empty host triggers failure
312+
}
313+
}
314+
315+
result := helm.VerifyUser(helmHandler, tt.req)
316+
assert.Equal(t, result, tt.wantResult)
317+
})
318+
}
319+
}

backend/pkg/serviceproxy/connection_test.go

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package serviceproxy //nolint
22

33
import (
4+
"bytes"
45
"net/http"
56
"net/http/httptest"
67
"testing"
@@ -41,23 +42,74 @@ func TestNewConnection(t *testing.T) {
4142
}
4243

4344
func TestGet(t *testing.T) {
45+
tests := []struct {
46+
name string
47+
uri string
48+
requestURI string
49+
wantBody []byte
50+
wantErr bool
51+
}{
52+
{
53+
name: "valid request",
54+
uri: "http://example.com",
55+
requestURI: "/test",
56+
wantBody: []byte("Hello, World!"),
57+
wantErr: false,
58+
},
59+
{
60+
name: "invalid URI",
61+
uri: " invalid-uri",
62+
requestURI: "/test",
63+
wantBody: nil,
64+
wantErr: true,
65+
},
66+
{
67+
name: "invalid request URI",
68+
uri: "http://example.com",
69+
requestURI: " invalid-request-uri",
70+
wantBody: nil,
71+
wantErr: true,
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
conn := &Connection{URI: tt.uri}
78+
79+
if tt.wantBody != nil {
80+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+
_, err := w.Write(tt.wantBody)
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
}))
86+
defer ts.Close()
87+
88+
conn.URI = ts.URL
89+
}
90+
91+
body, err := conn.Get(tt.requestURI)
92+
if (err != nil) != tt.wantErr {
93+
t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
94+
}
95+
96+
if !tt.wantErr && !bytes.Equal(body, tt.wantBody) {
97+
t.Errorf("Get() body = %s, want %s", body, tt.wantBody)
98+
}
99+
})
100+
}
101+
}
102+
103+
func TestGetNonOKStatusCode(t *testing.T) {
44104
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45-
if _, err := w.Write([]byte("Hello, World!")); err != nil {
46-
t.Fatalf("write test: %v", err)
47-
}
105+
w.WriteHeader(http.StatusInternalServerError)
48106
}))
49107
defer ts.Close()
50108

51-
// Create a connection to the test server
52-
conn := NewConnection(&proxyService{URIPrefix: ts.URL})
53-
54-
// Test Get()
55-
resp, err := conn.Get("/test")
56-
if err != nil {
57-
t.Errorf("Get() error = %v", err)
58-
}
109+
conn := &Connection{URI: ts.URL}
59110

60-
if string(resp) != "Hello, World!" {
61-
t.Errorf("Get() response = %s, want Hello, World!", resp)
111+
_, err := conn.Get("/test")
112+
if err == nil {
113+
t.Errorf("Get() error = nil, want error")
62114
}
63115
}

0 commit comments

Comments
 (0)