1
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
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
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
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
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
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
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 }
541
View as plain text