|
| 1 | +/* |
| 2 | +Copyright 2026 The Cozystack 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 openapi |
| 18 | + |
| 19 | +import ( |
| 20 | + "sort" |
| 21 | + "strings" |
| 22 | + "testing" |
| 23 | + |
| 24 | + "k8s.io/kube-openapi/pkg/common" |
| 25 | + "k8s.io/kube-openapi/pkg/validation/spec" |
| 26 | +) |
| 27 | + |
| 28 | +// The two tests below guard the published OpenAPI document against the class of |
| 29 | +// defect in https://github.com/cozystack/cozystack/issues/3806: a definition |
| 30 | +// name that is a Go import path rather than a dotted Kubernetes model name. |
| 31 | +// |
| 32 | +// Since Kubernetes 0.35 the apiserver's DefinitionNamer.GetDefinitionName |
| 33 | +// returns the model name it was given verbatim — it no longer converts the Go |
| 34 | +// import-path form into the "friendly" reversed-path form — so whatever name |
| 35 | +// this generated map uses becomes both the published definition key and, JSON |
| 36 | +// pointer escaped, the $ref that points at it. A name containing "/" therefore |
| 37 | +// ships as a definition keyed on the raw path while every reference to it is |
| 38 | +// spelled with "~1" in place of each slash, and clients resolve a $ref by |
| 39 | +// trimming "#/definitions/" without unescaping (kube-openapi |
| 40 | +// pkg/util/proto/document.go). The two spellings never meet, so the reference |
| 41 | +// dangles and client-side validation fails on every resource of the group: |
| 42 | +// |
| 43 | +// error validating data: SchemaError(…core/v1alpha1.Option.spec): |
| 44 | +// unknown model in reference: "github.com~1cozystack~1…v1alpha1.OptionSpec" |
| 45 | +// |
| 46 | +// The fix for a group is the +k8s:openapi-model-package marker in its doc.go |
| 47 | +// plus an OpenAPIModelName method per type (see each package's model_name.go). |
| 48 | +// Nothing asserted that, which is why the core and sdn groups shipped broken in |
| 49 | +// v1.6.0 and v1.6.1. These tests assert the invariant rather than today's set of |
| 50 | +// names, so a future group that omits the marker fails here instead of in a |
| 51 | +// cluster. |
| 52 | + |
| 53 | +// buildDefinitions builds the generated definition map, recording every model |
| 54 | +// name the generated code passes to the reference callback. Refs are built |
| 55 | +// exactly as kube-openapi's builder builds them, so the recorded name and the |
| 56 | +// emitted $ref differ in the same way they do in the served document. |
| 57 | +func buildDefinitions() (defs map[string]common.OpenAPIDefinition, refNames []string) { |
| 58 | + defs = GetOpenAPIDefinitions(func(path string) spec.Ref { |
| 59 | + refNames = append(refNames, path) |
| 60 | + return spec.MustCreateRef("#/definitions/" + common.EscapeJsonPointer(path)) |
| 61 | + }) |
| 62 | + return defs, refNames |
| 63 | +} |
| 64 | + |
| 65 | +// sortedKeys returns the definition names in a stable order so failures are |
| 66 | +// reproducible rather than map-iteration ordered. |
| 67 | +func sortedKeys(defs map[string]common.OpenAPIDefinition) []string { |
| 68 | + out := make([]string, 0, len(defs)) |
| 69 | + for name := range defs { |
| 70 | + out = append(out, name) |
| 71 | + } |
| 72 | + sort.Strings(out) |
| 73 | + return out |
| 74 | +} |
| 75 | + |
| 76 | +// TestDefinitionNamesAreDottedModelNames asserts that no published definition |
| 77 | +// name contains a slash. A slash-bearing name is a Go import path that escaped |
| 78 | +// as-is, which means the group it belongs to is missing its |
| 79 | +// +k8s:openapi-model-package marker and its OpenAPIModelName methods. |
| 80 | +func TestDefinitionNamesAreDottedModelNames(t *testing.T) { |
| 81 | + defs, _ := buildDefinitions() |
| 82 | + |
| 83 | + // Guard against the assertion going vacuous: the map must be populated and |
| 84 | + // must actually cover cozystack's own types, not only the vendored |
| 85 | + // apimachinery and apiextensions models that already declare their names. |
| 86 | + if len(defs) == 0 { |
| 87 | + t.Fatal("GetOpenAPIDefinitions returned no definitions; this test is asserting nothing") |
| 88 | + } |
| 89 | + own := 0 |
| 90 | + for _, name := range sortedKeys(defs) { |
| 91 | + if strings.Contains(name, "cozystack") { |
| 92 | + own++ |
| 93 | + } |
| 94 | + } |
| 95 | + if own == 0 { |
| 96 | + t.Fatal("no cozystack-owned definitions found; this test is asserting nothing") |
| 97 | + } |
| 98 | + |
| 99 | + for _, name := range sortedKeys(defs) { |
| 100 | + if strings.Contains(name, "/") { |
| 101 | + t.Errorf("definition %q is a Go import path, not a dotted model name: "+ |
| 102 | + "every $ref to it is escaped with ~1 and will not resolve. Add "+ |
| 103 | + "+k8s:openapi-model-package=<dotted package> to that package's doc.go and an "+ |
| 104 | + "OpenAPIModelName method for the type in its model_name.go, then re-run make generate", |
| 105 | + name) |
| 106 | + } |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +// TestDefinitionRefsResolve asserts that every $ref the generated document |
| 111 | +// emits resolves to a published definition, resolving it the way a client does: |
| 112 | +// trim "#/definitions/" and look the remainder up verbatim, with no |
| 113 | +// unescaping. This is the failure the user sees, so it is checked directly |
| 114 | +// rather than only through the slash-freedom proxy above; it also catches a |
| 115 | +// group whose types disagree with each other (some named, some not) and a ref |
| 116 | +// to a model that is not published at all. |
| 117 | +func TestDefinitionRefsResolve(t *testing.T) { |
| 118 | + defs, refNames := buildDefinitions() |
| 119 | + |
| 120 | + if len(refNames) == 0 { |
| 121 | + t.Fatal("generated definitions emitted no $ref; this test is asserting nothing") |
| 122 | + } |
| 123 | + |
| 124 | + // Index the published definitions under the key a client sees, which is the |
| 125 | + // definition name exactly as published. |
| 126 | + published := make(map[string]struct{}, len(defs)) |
| 127 | + for name := range defs { |
| 128 | + published[name] = struct{}{} |
| 129 | + } |
| 130 | + |
| 131 | + seen := make(map[string]struct{}, len(refNames)) |
| 132 | + for _, modelName := range refNames { |
| 133 | + // The served $ref, built the way kube-openapi's builder builds it. |
| 134 | + ref := "#/definitions/" + common.EscapeJsonPointer(modelName) |
| 135 | + // The lookup a client performs on it. |
| 136 | + resolved := strings.TrimPrefix(ref, "#/definitions/") |
| 137 | + if _, ok := published[resolved]; ok { |
| 138 | + continue |
| 139 | + } |
| 140 | + if _, dup := seen[resolved]; dup { |
| 141 | + continue |
| 142 | + } |
| 143 | + seen[resolved] = struct{}{} |
| 144 | + t.Errorf("$ref %q does not resolve: no definition is published under %q (model name %q)", |
| 145 | + ref, resolved, modelName) |
| 146 | + } |
| 147 | + |
| 148 | + // Dependencies must agree with the refs: the generated code lists them from |
| 149 | + // the same set, and consumers walk them to build the transitive closure. |
| 150 | + for _, name := range sortedKeys(defs) { |
| 151 | + for _, dep := range defs[name].Dependencies { |
| 152 | + if _, ok := published[dep]; !ok { |
| 153 | + t.Errorf("definition %q declares dependency %q, which is not published", name, dep) |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | +} |
0 commit comments