Skip to content

Commit cea1703

Browse files
test(api): guard published openapi definition names and refs
Nothing asserted that a published definition name contains no slash, which is why the core and sdn groups shipped as Go import paths in v1.6.0 and v1.6.1 without anyone noticing. The same omission on a future group would be just as invisible. TestDefinitionNamesAreDottedModelNames fails on any definition name containing "/", names the offender and points at the fix (the +k8s:openapi-model-package marker plus an OpenAPIModelName method). TestDefinitionRefsResolve is the failure mode itself: it builds each $ref the way kube-openapi's builder does, JSON pointer escaped, then resolves it the way a client does, trimming "#/definitions/" with no unescaping, and reports any that dangles. It also checks the declared Dependencies against the published names, which catches a group whose types disagree with each other rather than uniformly. Both assert the invariant instead of listing today's 20 names, so a new group that omits the marker is caught rather than a changed count, and both guard against going vacuous if the definition map is ever emptied or stops covering cozystack's own types. Both fail on the pre-fix tree: the first names all 20 offending definitions, and the second reproduces the reported error verbatim, down to "github.com~1cozystack~1cozystack~1pkg~1apis~1core~1v1alpha1.OptionSpec". Refs: #3806 Assisted-By: Claude <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent 5f3ad9c commit cea1703

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)