k8s.io/kube-openapi@v0.0.0-20240228011516-70dd3763d340/pkg/handler3/handler_test.go (about)

     1  /*
     2     Copyright 2021 The Kubernetes Authors.
     3  
     4     Licensed under the Apache License, Version 2.0 (the "License");
     5     you may not use this file except in compliance with the License.
     6     You may obtain a copy of the License at
     7  
     8  http://www.apache.org/licenses/LICENSE-2.0
     9  
    10     Unless required by applicable law or agreed to in writing, software
    11     distributed under the License is distributed on an "AS IS" BASIS,
    12     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13     See the License for the specific language governing permissions and
    14     limitations under the License.
    15  */
    16  
    17  package handler3
    18  
    19  import (
    20  	"bytes"
    21  	"fmt"
    22  	"io"
    23  	"mime"
    24  	"net/http"
    25  	"net/http/httptest"
    26  	"reflect"
    27  	"strconv"
    28  	"testing"
    29  	"time"
    30  
    31  	"encoding/json"
    32  
    33  	"k8s.io/kube-openapi/pkg/spec3"
    34  )
    35  
    36  var returnedOpenAPI = []byte(`{
    37    "openapi": "3.0",
    38    "info": {
    39     "title": "Kubernetes",
    40     "version": "v1.23.0"
    41    },
    42    "paths": {}}`)
    43  
    44  func TestRegisterOpenAPIVersionedService(t *testing.T) {
    45  	var s *spec3.OpenAPI
    46  	buffer := new(bytes.Buffer)
    47  	if err := json.Compact(buffer, returnedOpenAPI); err != nil {
    48  		t.Errorf("%v", err)
    49  	}
    50  	compactOpenAPI := buffer.Bytes()
    51  	var hash = computeETag(compactOpenAPI)
    52  
    53  	var returnedGroupVersionListJSON = []byte(`{"paths":{"apis/apps/v1":{"serverRelativeURL":"/openapi/v3/apis/apps/v1?hash=` + hash + `"}}}`)
    54  
    55  	json.Unmarshal(compactOpenAPI, &s)
    56  
    57  	returnedJSON, err := json.Marshal(s)
    58  	if err != nil {
    59  		t.Fatalf("Unexpected error in preparing returnedJSON: %v", err)
    60  	}
    61  
    62  	returnedPb, err := ToV3ProtoBinary(compactOpenAPI)
    63  
    64  	if err != nil {
    65  		t.Fatalf("Unexpected error in preparing returnedPb: %v", err)
    66  	}
    67  
    68  	mux := http.NewServeMux()
    69  	o := NewOpenAPIService()
    70  	if err != nil {
    71  		t.Fatal(err)
    72  	}
    73  
    74  	mux.Handle("/openapi/v3", http.HandlerFunc(o.HandleDiscovery))
    75  	mux.Handle("/openapi/v3/apis/apps/v1", http.HandlerFunc(o.HandleGroupVersion))
    76  
    77  	o.UpdateGroupVersion("apis/apps/v1", s)
    78  
    79  	server := httptest.NewServer(mux)
    80  	defer server.Close()
    81  	client := server.Client()
    82  
    83  	tcs := []struct {
    84  		acceptHeader              string
    85  		respStatus                int
    86  		urlPath                   string
    87  		respBody                  []byte
    88  		expectedETag              string
    89  		sendETag                  bool
    90  		responseContentTypeHeader string
    91  	}{
    92  		{
    93  			acceptHeader:              "",
    94  			respStatus:                200,
    95  			urlPath:                   "openapi/v3",
    96  			respBody:                  returnedGroupVersionListJSON,
    97  			expectedETag:              computeETag(returnedGroupVersionListJSON),
    98  			responseContentTypeHeader: "application/json",
    99  		}, {
   100  			acceptHeader: "",
   101  			respStatus:   304,
   102  			urlPath:      "openapi/v3",
   103  			respBody:     returnedGroupVersionListJSON,
   104  			expectedETag: computeETag(returnedGroupVersionListJSON),
   105  			sendETag:     true,
   106  		}, {
   107  			acceptHeader:              "",
   108  			respStatus:                200,
   109  			urlPath:                   "openapi/v3/apis/apps/v1",
   110  			respBody:                  returnedJSON,
   111  			expectedETag:              computeETag(returnedJSON),
   112  			responseContentTypeHeader: "application/json",
   113  		}, {
   114  			acceptHeader: "",
   115  			respStatus:   304,
   116  			urlPath:      "openapi/v3/apis/apps/v1",
   117  			respBody:     returnedJSON,
   118  			expectedETag: computeETag(returnedJSON),
   119  			sendETag:     true,
   120  		}, {
   121  			acceptHeader:              "*/*",
   122  			respStatus:                200,
   123  			urlPath:                   "openapi/v3/apis/apps/v1",
   124  			respBody:                  returnedJSON,
   125  			expectedETag:              computeETag(returnedJSON),
   126  			responseContentTypeHeader: "application/json",
   127  		}, {
   128  			acceptHeader:              "application/json",
   129  			respStatus:                200,
   130  			urlPath:                   "openapi/v3/apis/apps/v1",
   131  			respBody:                  returnedJSON,
   132  			expectedETag:              computeETag(returnedJSON),
   133  			responseContentTypeHeader: "application/json",
   134  		}, {
   135  			acceptHeader:              "application/*",
   136  			respStatus:                200,
   137  			urlPath:                   "openapi/v3/apis/apps/v1",
   138  			respBody:                  returnedJSON,
   139  			expectedETag:              computeETag(returnedJSON),
   140  			responseContentTypeHeader: "application/json",
   141  		}, {
   142  			acceptHeader: "test/test",
   143  			respStatus:   406,
   144  			urlPath:      "openapi/v3/apis/apps/v1",
   145  			respBody:     []byte{},
   146  		}, {
   147  			acceptHeader: "application/test",
   148  			respStatus:   406,
   149  			urlPath:      "openapi/v3/apis/apps/v1",
   150  			respBody:     []byte{},
   151  		}, {
   152  			acceptHeader:              "application/test,  */*",
   153  			respStatus:                200,
   154  			urlPath:                   "openapi/v3/apis/apps/v1",
   155  			respBody:                  returnedJSON,
   156  			expectedETag:              computeETag(returnedJSON),
   157  			responseContentTypeHeader: "application/json",
   158  		}, {
   159  			acceptHeader:              "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   160  			respStatus:                200,
   161  			urlPath:                   "openapi/v3/apis/apps/v1",
   162  			respBody:                  returnedPb,
   163  			expectedETag:              computeETag(returnedJSON),
   164  			responseContentTypeHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   165  		}, {
   166  			acceptHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   167  			respStatus:   304,
   168  			urlPath:      "openapi/v3/apis/apps/v1",
   169  			respBody:     returnedPb,
   170  			expectedETag: computeETag(returnedJSON),
   171  			sendETag:     true,
   172  		}, {
   173  			acceptHeader:              "application/json, application/com.github.proto-openapi.spec.v2.v1.0+protobuf",
   174  			respStatus:                200,
   175  			urlPath:                   "openapi/v3/apis/apps/v1",
   176  			respBody:                  returnedJSON,
   177  			expectedETag:              computeETag(returnedJSON),
   178  			responseContentTypeHeader: "application/json",
   179  		}, {
   180  			acceptHeader:              "application/com.github.proto-openapi.spec.v3.v1.0+protobuf, application/json",
   181  			respStatus:                200,
   182  			urlPath:                   "openapi/v3/apis/apps/v1",
   183  			respBody:                  returnedPb,
   184  			expectedETag:              computeETag(returnedJSON),
   185  			responseContentTypeHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   186  		}, {
   187  			acceptHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf, application/json",
   188  			respStatus:   304,
   189  			urlPath:      "openapi/v3/apis/apps/v1",
   190  			respBody:     returnedPb,
   191  			expectedETag: computeETag(returnedJSON),
   192  			sendETag:     true,
   193  		}, {
   194  			acceptHeader:              "application/com.github.proto-openapi.spec.v3.v1.0+protobuf; q=0.5, application/json",
   195  			respStatus:                200,
   196  			urlPath:                   "openapi/v3/apis/apps/v1",
   197  			respBody:                  returnedJSON,
   198  			expectedETag:              computeETag(returnedJSON),
   199  			responseContentTypeHeader: "application/json",
   200  		}, {
   201  			acceptHeader:              "application/com.github.proto-openapi.spec.v3@v1.0+protobuf",
   202  			respStatus:                200,
   203  			urlPath:                   "openapi/v3/apis/apps/v1",
   204  			respBody:                  returnedPb,
   205  			expectedETag:              computeETag(returnedJSON),
   206  			responseContentTypeHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   207  		}, {
   208  			acceptHeader: "application/com.github.proto-openapi.spec.v3@v1.0+protobuf",
   209  			respStatus:   304,
   210  			urlPath:      "openapi/v3/apis/apps/v1",
   211  			respBody:     returnedPb,
   212  			expectedETag: computeETag(returnedJSON),
   213  			sendETag:     true,
   214  		}, {
   215  			acceptHeader:              "application/com.github.proto-openapi.spec.v3@v1.0+protobuf, application/json",
   216  			respStatus:                200,
   217  			urlPath:                   "openapi/v3/apis/apps/v1",
   218  			respBody:                  returnedPb,
   219  			expectedETag:              computeETag(returnedJSON),
   220  			responseContentTypeHeader: "application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   221  		}, {
   222  			acceptHeader:              "application/com.github.proto-openapi.spec.v3@v1.0+protobuf; q=0.5, application/json",
   223  			respStatus:                200,
   224  			urlPath:                   "openapi/v3/apis/apps/v1",
   225  			respBody:                  returnedJSON,
   226  			expectedETag:              computeETag(returnedJSON),
   227  			responseContentTypeHeader: "application/json",
   228  		},
   229  	}
   230  
   231  	for _, tc := range tcs {
   232  		req, err := http.NewRequest("GET", server.URL+"/"+tc.urlPath, nil)
   233  		if err != nil {
   234  			t.Errorf("Accept: %v: Unexpected error in creating new request: %v", tc.acceptHeader, err)
   235  		}
   236  
   237  		req.Header.Add("Accept", tc.acceptHeader)
   238  		if tc.sendETag {
   239  			req.Header.Add("If-None-Match", strconv.Quote(tc.expectedETag))
   240  		}
   241  		resp, err := client.Do(req)
   242  		if err != nil {
   243  			t.Errorf("Accept: %v: Unexpected error in serving HTTP request: %v", tc.acceptHeader, err)
   244  		}
   245  		defer resp.Body.Close()
   246  
   247  		if resp.StatusCode != tc.respStatus {
   248  			t.Errorf("Accept: %v: Unexpected response status code, want: %v, got: %v", tc.acceptHeader, tc.respStatus, resp.StatusCode)
   249  		}
   250  
   251  		if tc.respStatus == 304 {
   252  			body, err := io.ReadAll(resp.Body)
   253  			if err != nil {
   254  				t.Errorf("Accept: %v: Unexpected error in reading response body: %v", tc.acceptHeader, err)
   255  			}
   256  			if len(body) != 0 {
   257  				t.Errorf("Response Body length must be 0 if 304 is returned.")
   258  			}
   259  		}
   260  		if tc.respStatus != 200 {
   261  			continue
   262  		}
   263  
   264  		responseContentType := resp.Header.Get("Content-Type")
   265  		if responseContentType != tc.responseContentTypeHeader {
   266  			t.Errorf("Accept: %v: Unexpected content type in response, want: %v, got: %v", tc.acceptHeader, tc.responseContentTypeHeader, responseContentType)
   267  		}
   268  		_, _, err = mime.ParseMediaType(responseContentType)
   269  		if err != nil {
   270  			t.Errorf("Unexpected error in parsing response content type: %v, err: %v", responseContentType, err)
   271  		}
   272  
   273  		gotETag := resp.Header.Get("ETag")
   274  		if strconv.Quote(tc.expectedETag) != gotETag {
   275  			t.Errorf("Expect ETag %s, got %s", strconv.Quote(tc.expectedETag), gotETag)
   276  		}
   277  
   278  		body, err := io.ReadAll(resp.Body)
   279  		if err != nil {
   280  			t.Errorf("Accept: %v: Unexpected error in reading response body: %v", tc.acceptHeader, err)
   281  		}
   282  		if !reflect.DeepEqual(body, tc.respBody) {
   283  			t.Errorf("Accept: %v: Response body mismatches, \nwant: %s, \ngot:  %s", tc.acceptHeader, string(tc.respBody), string(body))
   284  		}
   285  	}
   286  }
   287  
   288  func TestCacheBusting(t *testing.T) {
   289  	var s *spec3.OpenAPI
   290  	buffer := new(bytes.Buffer)
   291  	if err := json.Compact(buffer, returnedOpenAPI); err != nil {
   292  		t.Errorf("%v", err)
   293  	}
   294  	compactOpenAPI := buffer.Bytes()
   295  	var hash = computeETag(compactOpenAPI)
   296  
   297  	json.Unmarshal(compactOpenAPI, &s)
   298  
   299  	returnedJSON, err := json.Marshal(s)
   300  	if err != nil {
   301  		t.Fatalf("Unexpected error in preparing returnedJSON: %v", err)
   302  	}
   303  
   304  	returnedPb, err := ToV3ProtoBinary(compactOpenAPI)
   305  
   306  	if err != nil {
   307  		t.Fatalf("Unexpected error in preparing returnedPb: %v", err)
   308  	}
   309  
   310  	mux := http.NewServeMux()
   311  	o := NewOpenAPIService()
   312  	if err != nil {
   313  		t.Fatal(err)
   314  	}
   315  
   316  	mux.Handle("/openapi/v3", http.HandlerFunc(o.HandleDiscovery))
   317  	mux.Handle("/openapi/v3/apis/apps/v1", http.HandlerFunc(o.HandleGroupVersion))
   318  
   319  	o.UpdateGroupVersion("apis/apps/v1", s)
   320  
   321  	server := httptest.NewServer(mux)
   322  	defer server.Close()
   323  	client := server.Client()
   324  
   325  	tcs := []struct {
   326  		acceptHeader string
   327  		respStatus   int
   328  		urlPath      string
   329  		respBody     []byte
   330  		expectedHash string
   331  		cacheControl string
   332  	}{
   333  		// Correct hash should yield the proper expiry and Cache Control headers
   334  		{"application/json",
   335  			200,
   336  			"openapi/v3/apis/apps/v1?hash=" + hash,
   337  			returnedJSON,
   338  			hash,
   339  			"public, immutable",
   340  		},
   341  		{"application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   342  			200,
   343  			"openapi/v3/apis/apps/v1?hash=" + hash,
   344  			returnedPb,
   345  			hash,
   346  			"public, immutable",
   347  		},
   348  		// Incorrect hash should redirect to the page with the correct hash
   349  		{"application/json",
   350  			200,
   351  			"openapi/v3/apis/apps/v1?hash=OUTDATEDHASH",
   352  			returnedJSON,
   353  			hash,
   354  			"public, immutable",
   355  		},
   356  		{"application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   357  			200,
   358  			"openapi/v3/apis/apps/v1?hash=OUTDATEDHASH",
   359  			returnedPb,
   360  			hash,
   361  			"public, immutable",
   362  		},
   363  		// No hash should not return Cache Control information
   364  		{"application/json",
   365  			200,
   366  			"openapi/v3/apis/apps/v1",
   367  			returnedJSON,
   368  			"",
   369  			"",
   370  		},
   371  		{"application/com.github.proto-openapi.spec.v3.v1.0+protobuf",
   372  			200,
   373  			"openapi/v3/apis/apps/v1",
   374  			returnedPb,
   375  			"",
   376  			"",
   377  		},
   378  	}
   379  
   380  	for _, tc := range tcs {
   381  		req, err := http.NewRequest("GET", server.URL+"/"+tc.urlPath, nil)
   382  		if err != nil {
   383  			t.Errorf("Accept: %v: Unexpected error in creating new request: %v", tc.acceptHeader, err)
   384  		}
   385  
   386  		req.Header.Add("Accept", tc.acceptHeader)
   387  		resp, err := client.Do(req)
   388  		if err != nil {
   389  			t.Errorf("Accept: %v: Unexpected error in serving HTTP request: %v", tc.acceptHeader, err)
   390  		}
   391  
   392  		if resp.StatusCode != 200 {
   393  			t.Errorf("Accept: Unexpected response status code, want: %v, got: %v", 200, resp.StatusCode)
   394  		}
   395  
   396  		if cacheControl := resp.Header.Get("Cache-Control"); cacheControl != tc.cacheControl {
   397  			t.Errorf("Expected Cache Control %v, got %v", tc.cacheControl, cacheControl)
   398  		}
   399  
   400  		if tc.expectedHash != "" {
   401  			if hash := resp.Request.URL.Query().Get("hash"); hash != tc.expectedHash {
   402  				t.Errorf("Expected Hash: %s, got %s", tc.expectedHash, hash)
   403  			}
   404  
   405  			expires := resp.Header.Get("Expires")
   406  			parsedTime, err := time.Parse(time.RFC1123, expires)
   407  			if err != nil {
   408  				t.Errorf("Could not parse cache expiry %v", expires)
   409  			}
   410  
   411  			difference := parsedTime.Sub(time.Now()).Hours()
   412  			if difference <= 0 {
   413  				t.Errorf("Expected cache expiry to be in the future")
   414  			}
   415  		} else {
   416  			hash := resp.Request.URL.Query()["hash"]
   417  			if len(hash) != 0 {
   418  				t.Errorf("Expect no redirect and empty hash if the hash is not provide")
   419  			}
   420  			expires := resp.Header.Get("Expires")
   421  			if expires != "" {
   422  				t.Errorf("Expected an empty Expiry if hash is not provided,  got %v", expires)
   423  			}
   424  		}
   425  
   426  		defer resp.Body.Close()
   427  		body, err := io.ReadAll(resp.Body)
   428  		if err != nil {
   429  			t.Errorf("Accept: %v: Unexpected error in reading response body: %v", tc.acceptHeader, err)
   430  		}
   431  		if !reflect.DeepEqual(body, tc.respBody) {
   432  			t.Errorf("Accept: %v: Response body mismatches, \nwant: %s, \ngot:  %s", tc.acceptHeader, string(tc.respBody), string(body))
   433  		}
   434  	}
   435  }
   436  
   437  func openAPIOrDie(name string) *spec3.OpenAPI {
   438  	openapi := fmt.Sprintf(`{
   439    "openapi": "3.0",
   440    "info": {
   441     "title": "%s",
   442     "version": "v1.23.0"
   443    },
   444    "paths": {}}`, name)
   445  	spec := spec3.OpenAPI{}
   446  	if err := json.Unmarshal([]byte(openapi), &spec); err != nil {
   447  		panic(err)
   448  	}
   449  	return &spec
   450  }
   451  
   452  func getDiscovery(server *httptest.Server, path string) (*OpenAPIV3Discovery, string, error) {
   453  	client := server.Client()
   454  	req, err := http.NewRequest("GET", server.URL+"/"+path, nil)
   455  	if err != nil {
   456  		return nil, "", fmt.Errorf("error in creating new request: %v", err)
   457  	}
   458  
   459  	resp, err := client.Do(req)
   460  	if err != nil {
   461  		return nil, "", fmt.Errorf("error in serving HTTP request: %v", err)
   462  	}
   463  	if resp.StatusCode != 200 {
   464  		return nil, "", fmt.Errorf("unexpected response status code, want: %v, got: %v", 200, resp.StatusCode)
   465  	}
   466  	body, err := io.ReadAll(resp.Body)
   467  	if err != nil {
   468  		return nil, "", fmt.Errorf("Failed to read request body: %v", err)
   469  	}
   470  
   471  	discovery := &OpenAPIV3Discovery{}
   472  	if err := json.Unmarshal(body, &discovery); err != nil {
   473  		return nil, "", fmt.Errorf("failed to unmarshal discovery: %v", err)
   474  	}
   475  	return discovery, resp.Header.Get("etag"), nil
   476  }
   477  
   478  func TestUpdateGroupVersion(t *testing.T) {
   479  	mux := http.NewServeMux()
   480  	o := NewOpenAPIService()
   481  
   482  	mux.Handle("/openapi/v3", http.HandlerFunc(o.HandleDiscovery))
   483  
   484  	o.UpdateGroupVersion("apis/apps/v1", openAPIOrDie("apps-v1"))
   485  
   486  	server := httptest.NewServer(mux)
   487  	defer server.Close()
   488  
   489  	discovery, discovery_etag, err := getDiscovery(server, "/openapi/v3")
   490  	if err != nil {
   491  		t.Fatalf("failed to get /openapi/v3: %v", err)
   492  	}
   493  	etag, ok := discovery.Paths["apis/apps/v1"]
   494  	if !ok {
   495  		t.Fatalf("missing apis/apps/v1")
   496  	}
   497  
   498  	// Update with the same thing, make sure we don't update anything.
   499  	o.UpdateGroupVersion("apis/apps/v1", openAPIOrDie("apps-v1"))
   500  
   501  	discovery, discovery_etag_updated, err := getDiscovery(server, "/openapi/v3")
   502  	if err != nil {
   503  		t.Fatalf("failed to get /openapi/v3: %v", err)
   504  	}
   505  	if len(discovery.Paths) != 1 {
   506  		t.Fatalf("Invalid number of Paths, expected 1: %v", discovery.Paths)
   507  	}
   508  	etag_updated, ok := discovery.Paths["apis/apps/v1"]
   509  	if !ok {
   510  		t.Fatalf("missing apis/apps/v1")
   511  	}
   512  
   513  	if discovery_etag_updated != discovery_etag {
   514  		t.Fatalf("No-op update shouldn't update OpenAPI Discovery etag")
   515  	}
   516  
   517  	if etag_updated != etag {
   518  		t.Fatalf("No-op update shouldn't update OpenAPI etag")
   519  	}
   520  
   521  	// Add one more, make sure it's in the list
   522  	o.UpdateGroupVersion("apis/something/v1", openAPIOrDie("something-v1"))
   523  	discovery, _, err = getDiscovery(server, "/openapi/v3")
   524  	if err != nil {
   525  		t.Fatalf("failed to get /openapi/v3: %v", err)
   526  	}
   527  	if len(discovery.Paths) != 2 {
   528  		t.Fatalf("Invalid number of Paths, expected 2: %v", discovery.Paths)
   529  	}
   530  
   531  	// And remove
   532  	o.DeleteGroupVersion("apis/apps/v1")
   533  	discovery, _, err = getDiscovery(server, "/openapi/v3")
   534  	if err != nil {
   535  		t.Fatalf("failed to get /openapi/v3: %v", err)
   536  	}
   537  	if len(discovery.Paths) != 1 {
   538  		t.Fatalf("Invalid number of Paths, expected 2: %v", discovery.Paths)
   539  	}
   540  }