mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-28 23:57:08 +09:00
The gate read a name declaration only when it was an object, and diffed only the constraint keywords it knew. A declaration turned into a string or null, or a misspelled keyword, therefore looked like no change at all. Those are exactly the inputs on which the API server refuses every create of the kind, the harshest outcome a schema change can have. The gate now runs a changed declaration through the same parser the server uses and reports it when it cannot be enforced. Assisted-by: LLM Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
174 lines
6.7 KiB
Go
174 lines
6.7 KiB
Go
/*
|
|
Copyright 2026 The Cozystack Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package apigate
|
|
|
|
import "testing"
|
|
|
|
// res is a small constructor for building test snapshots.
|
|
func res(group, kind, plural string, src Source, schema Schema) Resource {
|
|
r := Resource{Group: group, Kind: kind, Plural: plural, Source: src, Versions: map[string]Schema{}}
|
|
if schema != nil {
|
|
r.Versions["v1alpha1"] = schema
|
|
}
|
|
return r
|
|
}
|
|
|
|
func snap(rs ...Resource) Snapshot {
|
|
s := Snapshot{}
|
|
for _, r := range rs {
|
|
s[r.key()] = r
|
|
}
|
|
return s
|
|
}
|
|
|
|
func countCategory(fs []Finding, c Category) int {
|
|
n := 0
|
|
for _, f := range fs {
|
|
if f.Category == c {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestClassify(t *testing.T) {
|
|
pgBase := res("apps.cozystack.io", "Postgres", "postgreses", SourceCozyRD,
|
|
Schema{"type": "object", "properties": map[string]any{"replicas": map[string]any{"type": "integer"}}})
|
|
pgBreaking := res("apps.cozystack.io", "Postgres", "postgreses", SourceCozyRD,
|
|
Schema{"type": "object", "properties": map[string]any{}})
|
|
|
|
t.Run("no change", func(t *testing.T) {
|
|
got := Classify(snap(pgBase), snap(pgBase))
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected no findings, got %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("new resource in existing group", func(t *testing.T) {
|
|
redis := res("apps.cozystack.io", "Redis", "redises", SourceCozyRD, Schema{"type": "object"})
|
|
got := Classify(snap(pgBase), snap(pgBase, redis))
|
|
if n := countCategory(got, NewResource); n != 1 {
|
|
t.Fatalf("expected 1 new-resource finding, got %d (%v)", n, got)
|
|
}
|
|
if countCategory(got, NewGroup) != 0 {
|
|
t.Fatalf("existing group must not be flagged as new: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("new group flags group once and does not double-count its resources", func(t *testing.T) {
|
|
sg1 := res("brandnew.cozystack.io", "Widget", "widgets", SourceCRD, Schema{"type": "object"})
|
|
sg2 := res("brandnew.cozystack.io", "Gadget", "gadgets", SourceCRD, Schema{"type": "object"})
|
|
got := Classify(snap(pgBase), snap(pgBase, sg1, sg2))
|
|
if n := countCategory(got, NewGroup); n != 1 {
|
|
t.Fatalf("expected exactly 1 new-group finding, got %d (%v)", n, got)
|
|
}
|
|
if n := countCategory(got, NewResource); n != 0 {
|
|
t.Fatalf("resources of a brand-new group must not also be flagged as new resources, got %d (%v)", n, got)
|
|
}
|
|
})
|
|
|
|
t.Run("breaking schema change", func(t *testing.T) {
|
|
got := Classify(snap(pgBase), snap(pgBreaking))
|
|
if n := countCategory(got, Breaking); n != 1 {
|
|
t.Fatalf("expected 1 breaking finding, got %d (%v)", n, got)
|
|
}
|
|
})
|
|
|
|
t.Run("removed resource is gated", func(t *testing.T) {
|
|
// Deleting a resource is the most disruptive single-resource change and
|
|
// must require owner review. The resource's group still has another
|
|
// member here, so this is a resource removal, not a group removal.
|
|
other := res("apps.cozystack.io", "Redis", "redises", SourceCozyRD, Schema{"type": "object"})
|
|
got := Classify(snap(pgBase, other), snap(other))
|
|
if n := countCategory(got, RemovedResource); n != 1 {
|
|
t.Fatalf("expected 1 removed-resource finding, got %d (%v)", n, got)
|
|
}
|
|
if countCategory(got, RemovedGroup) != 0 {
|
|
t.Fatalf("group still has a member; must not be flagged as removed group: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("removed group flags group once and does not double-count its resources", func(t *testing.T) {
|
|
g1 := res("gone.cozystack.io", "Widget", "widgets", SourceCRD, Schema{"type": "object"})
|
|
g2 := res("gone.cozystack.io", "Gadget", "gadgets", SourceCRD, Schema{"type": "object"})
|
|
got := Classify(snap(pgBase, g1, g2), snap(pgBase))
|
|
if n := countCategory(got, RemovedGroup); n != 1 {
|
|
t.Fatalf("expected exactly 1 removed-group finding, got %d (%v)", n, got)
|
|
}
|
|
if n := countCategory(got, RemovedResource); n != 0 {
|
|
t.Fatalf("resources of a fully-removed group must not also be flagged as removed resources, got %d (%v)", n, got)
|
|
}
|
|
})
|
|
|
|
t.Run("static go-backed new resource is detected", func(t *testing.T) {
|
|
base := snap(res("core.cozystack.io", "", "tenantsecrets", SourceAPIServer, nil))
|
|
head := snap(
|
|
res("core.cozystack.io", "", "tenantsecrets", SourceAPIServer, nil),
|
|
res("core.cozystack.io", "", "tenantwidgets", SourceAPIServer, nil),
|
|
)
|
|
got := Classify(base, head)
|
|
if n := countCategory(got, NewResource); n != 1 {
|
|
t.Fatalf("expected 1 new-resource finding for static group, got %d (%v)", n, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestClassifyNameDeclaration covers the constraints an application declares
|
|
// on its own metadata.name at the schema root: tightening one rejects names
|
|
// the base accepted, loosening or dropping one does not.
|
|
func TestClassifyNameDeclaration(t *testing.T) {
|
|
kube := func(declaration map[string]any) Resource {
|
|
s := Schema{"type": "object", "properties": map[string]any{}}
|
|
if declaration != nil {
|
|
s["x-cozystack-name"] = declaration
|
|
}
|
|
return res("apps.cozystack.io", "Kubernetes", "kuberneteses", SourceCozyRD, s)
|
|
}
|
|
capAt := func(n float64) map[string]any { return map[string]any{"type": "string", "maxLength": n} }
|
|
kubeRaw := func(declaration any) Resource {
|
|
s := Schema{"type": "object", "properties": map[string]any{}, "x-cozystack-name": declaration}
|
|
return res("apps.cozystack.io", "Kubernetes", "kuberneteses", SourceCozyRD, s)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
base, head Resource
|
|
breaking bool
|
|
}{
|
|
{"cap added", kube(nil), kube(capAt(32)), true},
|
|
{"cap lowered", kube(capAt(32)), kube(capAt(30)), true},
|
|
{"pattern added", kube(capAt(32)), kube(map[string]any{"type": "string", "maxLength": 32.0, "pattern": "^[a-z]+$"}), true},
|
|
{"cap raised", kube(capAt(32)), kube(capAt(40)), false},
|
|
{"cap dropped", kube(capAt(32)), kube(nil), false},
|
|
{"unchanged", kube(capAt(32)), kube(capAt(32)), false},
|
|
{"declaration turned into a string", kube(capAt(32)), kubeRaw("32"), true},
|
|
{"misspelled keyword", kube(nil), kube(map[string]any{"maxLenght": 32.0}), true},
|
|
{"null declaration", kube(capAt(32)), kubeRaw(nil), true},
|
|
{"annotation added", kube(capAt(32)), kube(map[string]any{"type": "string", "maxLength": 32.0, "title": "Name"}), false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := countCategory(Classify(snap(tc.base), snap(tc.head)), Breaking)
|
|
if tc.breaking && got != 1 {
|
|
t.Fatalf("expected 1 breaking finding, got %d", got)
|
|
}
|
|
if !tc.breaking && got != 0 {
|
|
t.Fatalf("expected no breaking finding, got %d", got)
|
|
}
|
|
})
|
|
}
|
|
}
|