@@ -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+ }
0 commit comments