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 }