1
16
17 package templates_test
18
19 import (
20 "bytes"
21 _ "embed"
22 "encoding/json"
23 "fmt"
24 "strings"
25 "testing"
26 "text/template"
27
28 "github.com/google/go-cmp/cmp"
29 "github.com/stretchr/testify/require"
30 "k8s.io/apimachinery/pkg/runtime/schema"
31 utilruntime "k8s.io/apimachinery/pkg/util/runtime"
32 "k8s.io/kube-openapi/pkg/spec3"
33 "k8s.io/kube-openapi/pkg/validation/spec"
34 v2 "k8s.io/kubectl/pkg/explain/v2"
35 )
36
37 var (
38
39 plaintextSource string
40
41
42 apiextensionsJSON string
43
44
45 batchJSON string
46
47 apiExtensionsV1OpenAPI map[string]interface{} = func() map[string]interface{} {
48 var res map[string]interface{}
49 utilruntime.Must(json.Unmarshal([]byte(apiextensionsJSON), &res))
50 return res
51 }()
52
53 apiExtensionsV1OpenAPIWithoutListVerb map[string]interface{} = func() map[string]interface{} {
54 var res map[string]interface{}
55 utilruntime.Must(json.Unmarshal([]byte(apiextensionsJSON), &res))
56 paths := res["paths"].(map[string]interface{})
57 delete(paths, "/apis/apiextensions.k8s.io/v1/customresourcedefinitions")
58 return res
59 }()
60
61 apiExtensionsV1OpenAPISpec spec3.OpenAPI = func() spec3.OpenAPI {
62 var res spec3.OpenAPI
63 utilruntime.Must(json.Unmarshal([]byte(apiextensionsJSON), &res))
64 return res
65 }()
66
67 batchV1OpenAPI map[string]interface{} = func() map[string]interface{} {
68 var res map[string]interface{}
69 utilruntime.Must(json.Unmarshal([]byte(batchJSON), &res))
70 return res
71 }()
72
73 batchV1OpenAPIWithoutListVerb map[string]interface{} = func() map[string]interface{} {
74 var res map[string]interface{}
75 utilruntime.Must(json.Unmarshal([]byte(batchJSON), &res))
76 paths := res["paths"].(map[string]interface{})
77 delete(paths, "/apis/batch/v1/jobs")
78 delete(paths, "/apis/batch/v1/namespaces/{namespace}/jobs")
79
80 delete(paths, "/apis/batch/v1/cronjobs")
81 delete(paths, "/apis/batch/v1/namespaces/{namespace}/cronjobs/{name}")
82 return res
83 }()
84 )
85
86 type testCase struct {
87
88 Name string
89
90 Subtemplate string
91
92 Context any
93
94 Checks []check
95 }
96
97 type check interface {
98 doCheck(output string, err error) error
99 }
100
101 type checkError string
102
103 func (c checkError) doCheck(output string, err error) error {
104 if !strings.Contains(err.Error(), "error: "+string(c)) {
105 return fmt.Errorf("expected error: '%v' in string:\n%v", string(c), err)
106 }
107 return nil
108 }
109
110 type checkContains string
111
112 func (c checkContains) doCheck(output string, err error) error {
113 if !strings.Contains(output, string(c)) {
114 return fmt.Errorf("expected substring: '%v' in string:\n%v", string(c), output)
115 }
116 return nil
117 }
118
119 type checkEquals string
120
121 func (c checkEquals) doCheck(output string, err error) error {
122 if output != string(c) {
123 return fmt.Errorf("output is not equal to expectation:\n%v", cmp.Diff(string(c), output))
124 }
125 return nil
126 }
127
128 func MapDict[K comparable, V any, N any](accum map[K]V, mapper func(V) N) map[K]N {
129 res := make(map[K]N, len(accum))
130 for k, v := range accum {
131 res[k] = mapper(v)
132 }
133 return res
134 }
135
136 func ReduceDict[K comparable, V any, N any](val map[K]V, accum N, mapper func(N, K, V) N) N {
137 for k, v := range val {
138 accum = mapper(accum, k, v)
139 }
140 return accum
141 }
142
143 func TestPlaintext(t *testing.T) {
144 testcases := []testCase{
145 {
146
147 Name: "ResourceNotFound",
148 Context: v2.TemplateContext{
149 Document: apiExtensionsV1OpenAPI,
150 GVR: schema.GroupVersionResource{},
151 FieldPath: nil,
152 Recursive: false,
153 },
154 Checks: []check{
155 checkError("GVR (/, Resource=) not found in OpenAPI schema"),
156 },
157 },
158 {
159
160 Name: "SchemaFound",
161 Context: v2.TemplateContext{
162 Document: apiExtensionsV1OpenAPI,
163 GVR: schema.GroupVersionResource{
164 Group: "apiextensions.k8s.io",
165 Version: "v1",
166 Resource: "customresourcedefinitions",
167 },
168 FieldPath: nil,
169 Recursive: false,
170 },
171 Checks: []check{
172 checkContains("CustomResourceDefinition represents a resource that should be exposed"),
173 },
174 },
175 {
176
177 Name: "SchemaFoundNamespaced",
178 Context: v2.TemplateContext{
179 Document: batchV1OpenAPI,
180 GVR: schema.GroupVersionResource{
181 Group: "batch",
182 Version: "v1",
183 Resource: "jobs",
184 },
185 FieldPath: nil,
186 Recursive: false,
187 },
188 Checks: []check{
189 checkContains("Job represents the configuration of a single job"),
190 },
191 },
192 {
193
194 Name: "SchemaFoundWithoutListVerb",
195 Context: v2.TemplateContext{
196 Document: apiExtensionsV1OpenAPIWithoutListVerb,
197 GVR: schema.GroupVersionResource{
198 Group: "apiextensions.k8s.io",
199 Version: "v1",
200 Resource: "customresourcedefinitions",
201 },
202 FieldPath: nil,
203 Recursive: false,
204 },
205 Checks: []check{
206 checkContains("CustomResourceDefinition represents a resource that should be exposed"),
207 },
208 },
209 {
210
211 Name: "SchemaFoundNamespacedWithoutListVerb",
212 Context: v2.TemplateContext{
213 Document: batchV1OpenAPIWithoutListVerb,
214 GVR: schema.GroupVersionResource{
215 Group: "batch",
216 Version: "v1",
217 Resource: "jobs",
218 },
219 FieldPath: nil,
220 Recursive: false,
221 },
222 Checks: []check{
223 checkContains("Job represents the configuration of a single job"),
224 },
225 },
226 {
227
228 Name: "SchemaFoundNamespacedWithoutTopLevelListVerb",
229 Context: v2.TemplateContext{
230 Document: batchV1OpenAPIWithoutListVerb,
231 GVR: schema.GroupVersionResource{
232 Group: "batch",
233 Version: "v1",
234 Resource: "cronjobs",
235 },
236 FieldPath: nil,
237 Recursive: false,
238 },
239 Checks: []check{
240 checkContains("CronJob represents the configuration of a single cron job"),
241 },
242 },
243 {
244
245
246 Name: "SchemaFieldPathNotFound",
247 Context: v2.TemplateContext{
248 Document: apiExtensionsV1OpenAPI,
249 GVR: schema.GroupVersionResource{
250 Group: "apiextensions.k8s.io",
251 Version: "v1",
252 Resource: "customresourcedefinitions",
253 },
254 FieldPath: []string{"does", "not", "exist"},
255 Recursive: false,
256 },
257 Checks: []check{
258 checkError(`field "exist" does not exist`),
259 },
260 },
261 {
262
263 Name: "SchemaFieldPathShallow",
264 Context: v2.TemplateContext{
265 Document: apiExtensionsV1OpenAPI,
266 GVR: schema.GroupVersionResource{
267 Group: "apiextensions.k8s.io",
268 Version: "v1",
269 Resource: "customresourcedefinitions",
270 },
271 FieldPath: []string{"kind"},
272 Recursive: false,
273 },
274 Checks: []check{
275 checkContains("FIELD: kind <string>"),
276 },
277 },
278 {
279
280 Name: "SchemaFieldPathDeep",
281 Context: v2.TemplateContext{
282 Document: apiExtensionsV1OpenAPI,
283 GVR: schema.GroupVersionResource{
284 Group: "apiextensions.k8s.io",
285 Version: "v1",
286 Resource: "customresourcedefinitions",
287 },
288 FieldPath: []string{"spec", "names", "singular"},
289 Recursive: false,
290 },
291 Checks: []check{
292 checkContains("FIELD: singular <string>"),
293 },
294 },
295 {
296
297
298 Name: "SchemaFieldPathViaList",
299 Context: v2.TemplateContext{
300 Document: apiExtensionsV1OpenAPI,
301 GVR: schema.GroupVersionResource{
302 Group: "apiextensions.k8s.io",
303 Version: "v1",
304 Resource: "customresourcedefinitions",
305 },
306 FieldPath: []string{"spec", "versions", "name"},
307 Recursive: false,
308 },
309 Checks: []check{
310 checkContains("FIELD: name <string>"),
311 },
312 },
313 {
314
315
316 Name: "SchemaFieldPathViaMap",
317 Context: v2.TemplateContext{
318 Document: apiExtensionsV1OpenAPI,
319 GVR: schema.GroupVersionResource{
320 Group: "apiextensions.k8s.io",
321 Version: "v1",
322 Resource: "customresourcedefinitions",
323 },
324 FieldPath: []string{"spec", "versions", "schema", "openAPIV3Schema", "properties", "default"},
325 Recursive: false,
326 },
327 Checks: []check{
328 checkContains("default is a default value for undefined object fields"),
329 },
330 },
331 {
332
333 Name: "SchemaFieldPathRecursive",
334 Context: v2.TemplateContext{
335 Document: apiExtensionsV1OpenAPI,
336 GVR: schema.GroupVersionResource{
337 Group: "apiextensions.k8s.io",
338 Version: "v1",
339 Resource: "customresourcedefinitions",
340 },
341 FieldPath: []string{"spec", "versions", "schema", "openAPIV3Schema", "properties", "properties", "properties", "properties", "properties", "default"},
342 Recursive: false,
343 },
344 Checks: []check{
345 checkContains("default is a default value for undefined object fields"),
346 },
347 },
348 {
349
350 Name: "SchemaAllFields",
351 Context: v2.TemplateContext{
352 Document: apiExtensionsV1OpenAPI,
353 GVR: schema.GroupVersionResource{
354 Group: "apiextensions.k8s.io",
355 Version: "v1",
356 Resource: "customresourcedefinitions",
357 },
358 FieldPath: []string{"spec", "versions", "schema"},
359 Recursive: false,
360 },
361 Checks: ReduceDict(apiExtensionsV1OpenAPISpec.Components.Schemas["io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation"].Properties, []check{}, func(checks []check, k string, v spec.Schema) []check {
362 return append(checks, checkContains(k), checkContains(v.Description))
363 }),
364 },
365 {
366
367 Name: "SchemaAllFieldsRecursive",
368 Context: v2.TemplateContext{
369 Document: apiExtensionsV1OpenAPI,
370 GVR: schema.GroupVersionResource{
371 Group: "apiextensions.k8s.io",
372 Version: "v1",
373 Resource: "customresourcedefinitions",
374 },
375 FieldPath: []string{"spec", "versions", "schema"},
376 Recursive: true,
377 },
378 Checks: ReduceDict(apiExtensionsV1OpenAPISpec.Components.Schemas["io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation"].Properties, []check{}, func(checks []check, k string, v spec.Schema) []check {
379 return append(checks, checkContains(k))
380 }),
381 },
382 {
383
384 Name: "Scalar",
385 Subtemplate: "typeGuess",
386 Context: map[string]any{
387 "schema": map[string]any{
388 "type": "string",
389 },
390 },
391 Checks: []check{
392 checkEquals("string"),
393 },
394 },
395 {
396
397 Name: "PrimitiveRef",
398 Subtemplate: "typeGuess",
399 Context: map[string]any{
400 "schema": map[string]any{
401 "description": "a cool field",
402 "$ref": "#/components/schemas/v1.Time",
403 },
404 "Document": map[string]any{
405 "components": map[string]any{
406 "schemas": map[string]any{
407 "v1.Time": map[string]any{
408 "type": "string",
409 "format": "date-time",
410 },
411 },
412 },
413 },
414 },
415 Checks: []check{
416 checkEquals("string"),
417 },
418 },
419 {
420
421
422 Name: "ArrayUnknown",
423 Subtemplate: "typeGuess",
424 Context: map[string]any{
425 "schema": map[string]any{
426 "description": "a cool field",
427 "type": "array",
428 },
429 },
430 Checks: []check{
431 checkEquals("array"),
432 },
433 },
434 {
435
436 Name: "ObjectTitle",
437 Subtemplate: "typeGuess",
438 Context: map[string]any{
439 "schema": map[string]any{
440 "description": "a cool field",
441 "type": "object",
442 },
443 },
444 Checks: []check{
445 checkEquals("Object"),
446 },
447 },
448 {
449
450 Name: "ArrayOfScalar",
451 Subtemplate: "typeGuess",
452 Context: map[string]any{
453 "schema": map[string]any{
454 "description": "a cool field",
455 "type": "array",
456 "items": map[string]any{
457 "type": "number",
458 },
459 },
460 },
461 Checks: []check{
462 checkEquals("[]number"),
463 },
464 },
465 {
466
467
468
469 Name: "ArrayOfAllOfRef",
470 Subtemplate: "typeGuess",
471 Context: map[string]any{
472 "schema": map[string]any{
473 "description": "a cool field",
474 "type": "array",
475 "items": map[string]any{
476 "type": "object",
477 "allOf": []map[string]any{
478 {
479 "$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
480 },
481 },
482 },
483 },
484 },
485 Checks: []check{
486 checkEquals("[]CustomResourceValidation"),
487 },
488 },
489 {
490
491
492 Name: "ArrayOfRef",
493 Subtemplate: "typeGuess",
494 Context: map[string]any{
495 "schema": map[string]any{
496 "description": "a cool field",
497 "type": "array",
498 "items": map[string]any{
499 "type": "object",
500 "$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
501 },
502 },
503 },
504 Checks: []check{
505 checkEquals("[]CustomResourceValidation"),
506 },
507 },
508 {
509
510 Name: "ArrayOfMap",
511 Subtemplate: "typeGuess",
512 Context: map[string]any{
513 "schema": map[string]any{
514 "description": "a cool field",
515 "type": "array",
516 "items": map[string]any{
517 "type": "object",
518 "additionalProperties": map[string]any{
519 "type": "string",
520 },
521 },
522 },
523 },
524 Checks: []check{
525 checkEquals("[]map[string]string"),
526 },
527 },
528 {
529
530 Name: "MapOfArrayOfScalar",
531 Subtemplate: "typeGuess",
532 Context: map[string]any{
533 "schema": map[string]any{
534 "description": "a cool field",
535 "type": "object",
536 "additionalProperties": map[string]any{
537 "type": "array",
538 "items": map[string]any{
539 "type": "string",
540 },
541 },
542 },
543 },
544 Checks: []check{
545 checkEquals("map[string][]string"),
546 },
547 },
548 {
549
550 Name: "MapOfRef",
551 Subtemplate: "typeGuess",
552 Context: map[string]any{
553 "schema": map[string]any{
554 "description": "a cool field",
555 "type": "object",
556 "additionalProperties": map[string]any{
557 "type": "string",
558 "allOf": []map[string]any{
559 {
560 "$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
561 },
562 },
563 },
564 },
565 },
566 Checks: []check{
567 checkEquals("map[string]CustomResourceValidation"),
568 },
569 },
570 {
571
572
573 Name: "Unknown",
574 Subtemplate: "typeGuess",
575 Context: map[string]any{
576 "schema": map[string]any{
577 "description": "a cool field",
578 },
579 },
580 Checks: []check{
581 checkEquals("Object"),
582 },
583 },
584 {
585 Name: "Required",
586 Subtemplate: "fieldDetail",
587 Context: map[string]any{
588 "schema": map[string]any{
589 "type": "object",
590 "description": "a description that should not be printed",
591 "properties": map[string]any{
592 "thefield": map[string]any{
593 "type": "string",
594 "description": "a description that should not be printed",
595 },
596 },
597 "required": []string{"thefield"},
598 },
599 "name": "thefield",
600 "short": true,
601 },
602 Checks: []check{
603 checkEquals("thefield\t<string> -required-\n"),
604 },
605 },
606 {
607 Name: "Description",
608 Subtemplate: "fieldDetail",
609 Context: map[string]any{
610 "schema": map[string]any{
611 "type": "object",
612 "description": "a description that should not be printed",
613 "properties": map[string]any{
614 "thefield": map[string]any{
615 "type": "string",
616 "description": "a description that should be printed",
617 },
618 },
619 "required": []string{"thefield"},
620 },
621 "name": "thefield",
622 "short": false,
623 },
624 Checks: []check{
625 checkEquals("thefield\t<string> -required-\n a description that should be printed\n\n"),
626 },
627 },
628 {
629 Name: "Indent",
630 Subtemplate: "fieldDetail",
631 Context: map[string]any{
632 "schema": map[string]any{
633 "type": "object",
634 "description": "a description that should not be printed",
635 "properties": map[string]any{
636 "thefield": map[string]any{
637 "type": "string",
638 "description": "a description that should not be printed",
639 },
640 },
641 "required": []string{"thefield"},
642 },
643 "name": "thefield",
644 "short": true,
645 "level": 5,
646 },
647 Checks: []check{
648 checkEquals(" thefield\t<string> -required-\n"),
649 },
650 },
651 {
652
653 Name: "extractEmptyEnum",
654 Subtemplate: "extractEnum",
655 Context: map[string]any{
656 "schema": map[string]any{
657 "type": "string",
658 "description": "a description that should not be printed",
659 "enum": []any{},
660 },
661 },
662 Checks: []check{
663 checkEquals(""),
664 },
665 },
666 {
667
668 Name: "extractEnumSimpleForm",
669 Subtemplate: "extractEnum",
670 Context: map[string]any{
671 "schema": map[string]any{
672 "type": "string",
673 "description": "a description that should not be printed",
674 "enum": []any{0, 1, 2, 3},
675 },
676 "isLongView": true,
677 },
678 Checks: []check{
679 checkEquals("ENUM:\n 0\n 1\n 2\n 3"),
680 },
681 },
682 {
683
684 Name: "extractEnumLongFormWithIndent",
685 Subtemplate: "extractEnum",
686 Context: map[string]any{
687 "schema": map[string]any{
688 "type": "string",
689 "description": "a description that should not be printed",
690 "enum": []any{0, 1, 2, 3},
691 },
692 "isLongView": false,
693 "indentAmount": 2,
694 },
695 Checks: []check{
696 checkEquals("\n enum: 0, 1, 2, 3"),
697 },
698 },
699 {
700
701 Name: "extractEnumLongFormWithLimitAndIndent",
702 Subtemplate: "extractEnum",
703 Context: map[string]any{
704 "schema": map[string]any{
705 "type": "string",
706 "description": "a description that should not be printed",
707 "enum": []any{0, 1, 2, 3},
708 },
709 "isLongView": false,
710 "limit": 2,
711 "indentAmount": 2,
712 },
713 Checks: []check{
714 checkEquals("\n enum: 0, 1, ...."),
715 },
716 },
717 {
718
719 Name: "extractEnumSimpleFormWithLimitAndIndent",
720 Subtemplate: "extractEnum",
721 Context: map[string]any{
722 "schema": map[string]any{
723 "type": "string",
724 "description": "a description that should not be printed",
725 "enum": []any{0, 1, 2, 3},
726 },
727 "isLongView": true,
728 "limit": 2,
729 "indentAmount": 2,
730 },
731 Checks: []check{
732 checkEquals("ENUM:\n 0\n 1, ...."),
733 },
734 },
735 {
736
737 Name: "extractEnumSimpleFormEmptyString",
738 Subtemplate: "extractEnum",
739 Context: map[string]any{
740 "schema": map[string]any{
741 "type": "string",
742 "description": "a description that should not be printed",
743 "enum": []any{"Block", "File", ""},
744 },
745 "isLongView": true,
746 },
747 Checks: []check{
748 checkEquals("ENUM:\n Block\n File\n \"\""),
749 },
750 },
751 }
752
753 tmpl, err := v2.WithBuiltinTemplateFuncs(template.New("")).Parse(plaintextSource)
754 require.NoError(t, err)
755
756 for _, tcase := range testcases {
757 testName := tcase.Name
758 if len(tcase.Subtemplate) > 0 {
759 testName = tcase.Subtemplate + "/" + testName
760 }
761
762 t.Run(testName, func(t *testing.T) {
763 buf := bytes.NewBuffer(nil)
764
765 var outputErr error
766 if len(tcase.Subtemplate) == 0 {
767 outputErr = tmpl.Execute(buf, tcase.Context)
768 } else {
769 outputErr = tmpl.ExecuteTemplate(buf, tcase.Subtemplate, tcase.Context)
770 }
771
772 output := buf.String()
773 for _, check := range tcase.Checks {
774 err = check.doCheck(output, outputErr)
775
776 if err != nil {
777 t.Log("test failed on output:\n" + output)
778 require.NoError(t, err)
779 }
780 }
781 })
782 }
783 }
784
View as plain text