mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
This mostly eliminates the need to hand-write validation code. This PR is a long series of commits which add DV for most of Actor and all of Atespace. Here is a map to the commits: * The first few take a dep on a new Kubernetes tag, import the code into third_party, and apply a single patch. Because we have different Go modules for tools, I had to do it twice. When that patch lands, we can revert these commits, but that won't be until the 1.38 cycle in a few months. * The next commits slowly add DV support, so a human can review each of them in a reasonable amount of time. The emphasis is on great test cases. * I made a bad choice early on as to where to generate the code into, so I moved it. Rebasing on that was exceedingly hard, so I left it as a move. * This required changing update/go-generate -> update/codegen -- we need to get the ordering of tools right, which `go generate` does not guarantee. * Then I added a "middle" layer called "ServiceImpl" between the RPC and storage layers. This allows things like workflow to call the same business logic as the RPC layer, including validation. Lots of test fixes. * Then I finished the Create() and Update() paths for Actor. Those represent the "right" (or closest to) way to implement resources, and tests for validation. I strongly encourage reviewers to read it commit-by-commit. Rebasing this is VERY tedious, so the sooner it lands or dies completely, the better. Then we can start converting the rest. `./hack/run-tool.sh validation-gen --docs` will produce some docs on the tool and the available tags. @laoj2 @juli4n @EItanya @HavenXia @lalitc375 @yongruilin @jpbetz FYI
225 lines
7.4 KiB
Go
225 lines
7.4 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// 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 controlapi
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
)
|
|
|
|
func TestValidateCreateAtespaceRequest(t *testing.T) {
|
|
// This test verifies validation of user input for creation.
|
|
validReq := func(atespace *ateapipb.Atespace, mods ...func(atespace *ateapipb.CreateAtespaceRequest)) *ateapipb.CreateAtespaceRequest {
|
|
req := &ateapipb.CreateAtespaceRequest{
|
|
Atespace: atespace,
|
|
}
|
|
for _, m := range mods {
|
|
m(req)
|
|
}
|
|
return req
|
|
}
|
|
withMetadata := withAtespaceMetadata
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.CreateAtespaceRequest
|
|
want field.ErrorList
|
|
}{{
|
|
"valid",
|
|
validReq(validAtespace()),
|
|
nil,
|
|
}, {
|
|
"unknown field on atespace",
|
|
&ateapipb.CreateAtespaceRequest{Atespace: withUnknown(&ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}, 9999)},
|
|
field.ErrorList{field.Invalid(field.NewPath("atespace"), field.OmitValueType{}, "")},
|
|
}, {
|
|
"missing atespace",
|
|
&ateapipb.CreateAtespaceRequest{Atespace: nil},
|
|
field.ErrorList{field.Required(field.NewPath("atespace"), "")},
|
|
}, {
|
|
"missing atespace.metadata",
|
|
validReq(validAtespace(func(a *ateapipb.Atespace) { a.Metadata = nil })),
|
|
field.ErrorList{field.Required(field.NewPath("atespace", "metadata"), "")},
|
|
}, {
|
|
"atespace.metadata.atespace must be empty",
|
|
validReq(validAtespace(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Atespace = "as" }))),
|
|
field.ErrorList{field.Forbidden(field.NewPath("atespace", "metadata", "atespace"), "")},
|
|
}, {
|
|
"missing atespace.metadata.name",
|
|
validReq(validAtespace(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "" }))),
|
|
field.ErrorList{field.Required(field.NewPath("atespace", "metadata", "name"), "")},
|
|
}, {
|
|
"invalid metadata.name",
|
|
validReq(validAtespace(withMetadata(func(m *ateapipb.ResourceMetadata) { m.Name = "invalid value" }))),
|
|
field.ErrorList{field.Invalid(field.NewPath("atespace", "metadata", "name"), nil, "").WithOrigin("format=k8s-short-name")},
|
|
}}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assertValidateErr(t, validateCreateAtespaceRequest(context.Background(), tt.req), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateGetAtespaceRequest(t *testing.T) {
|
|
// This test verifies validation of user input for get.
|
|
validReq := func(mods ...func(atespace *ateapipb.GetAtespaceRequest)) *ateapipb.GetAtespaceRequest {
|
|
req := &ateapipb.GetAtespaceRequest{
|
|
Atespace: &ateapipb.ObjectRef{Name: "team1"},
|
|
}
|
|
for _, m := range mods {
|
|
m(req)
|
|
}
|
|
return req
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.GetAtespaceRequest
|
|
want field.ErrorList
|
|
}{{
|
|
"valid",
|
|
validReq(),
|
|
nil,
|
|
}, {
|
|
"missing atespace",
|
|
&ateapipb.GetAtespaceRequest{},
|
|
field.ErrorList{field.Required(field.NewPath("atespace"), "")},
|
|
}, {
|
|
"atespace.atespace must be empty",
|
|
validReq(func(r *ateapipb.GetAtespaceRequest) { r.Atespace.Atespace = "as" }),
|
|
field.ErrorList{field.Forbidden(field.NewPath("atespace", "atespace"), "")},
|
|
}, {
|
|
"missing atespace.name",
|
|
validReq(func(r *ateapipb.GetAtespaceRequest) { r.Atespace.Name = "" }),
|
|
field.ErrorList{field.Required(field.NewPath("atespace", "name"), "")},
|
|
}, {
|
|
"invalid atespace.name",
|
|
validReq(func(r *ateapipb.GetAtespaceRequest) { r.Atespace.Name = "invalid value" }),
|
|
field.ErrorList{field.Invalid(field.NewPath("atespace", "name"), nil, "").WithOrigin("format=k8s-short-name")},
|
|
}}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assertValidateErr(t, validateGetAtespaceRequest(context.Background(), tt.req), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateListAtespacesRequest(t *testing.T) {
|
|
// This test verifies validation of user input for list.
|
|
validReq := func(mods ...func(atespace *ateapipb.ListAtespacesRequest)) *ateapipb.ListAtespacesRequest {
|
|
req := &ateapipb.ListAtespacesRequest{ /* default values */ }
|
|
for _, m := range mods {
|
|
m(req)
|
|
}
|
|
return req
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.ListAtespacesRequest
|
|
want field.ErrorList
|
|
}{{
|
|
"valid, no page_size",
|
|
validReq(),
|
|
nil,
|
|
}, {
|
|
"valid, positive page_size",
|
|
validReq(func(r *ateapipb.ListAtespacesRequest) { r.PageSize = 10 }),
|
|
nil,
|
|
}, {
|
|
"negative page_size",
|
|
validReq(func(r *ateapipb.ListAtespacesRequest) { r.PageSize = -1 }),
|
|
field.ErrorList{field.Invalid(field.NewPath("page_size"), int32(-1), "").WithOrigin("minimum")},
|
|
}, {
|
|
"valid page_token",
|
|
validReq(func(r *ateapipb.ListAtespacesRequest) { r.PageToken = strings.Repeat("x", 256) }),
|
|
nil,
|
|
}, {
|
|
"too-large page_token",
|
|
validReq(func(r *ateapipb.ListAtespacesRequest) { r.PageToken = strings.Repeat("x", 257) }),
|
|
field.ErrorList{field.TooLongCharacters(field.NewPath("page_token"), "", 256).WithOrigin("maxLength")},
|
|
}}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assertValidateErr(t, validateListAtespacesRequest(context.Background(), tt.req), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateDeleteAtespaceRequest(t *testing.T) {
|
|
// This test verifies validation of user input for delete.
|
|
validReq := func(mods ...func(atespace *ateapipb.DeleteAtespaceRequest)) *ateapipb.DeleteAtespaceRequest {
|
|
req := &ateapipb.DeleteAtespaceRequest{
|
|
Atespace: &ateapipb.ObjectRef{Name: "team1"},
|
|
}
|
|
for _, m := range mods {
|
|
m(req)
|
|
}
|
|
return req
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *ateapipb.DeleteAtespaceRequest
|
|
want field.ErrorList
|
|
}{{
|
|
"valid",
|
|
validReq(),
|
|
nil,
|
|
}, {
|
|
"missing atespace",
|
|
&ateapipb.DeleteAtespaceRequest{Atespace: nil},
|
|
field.ErrorList{field.Required(field.NewPath("atespace"), "")},
|
|
}, {
|
|
"atespace.atespace must be empty",
|
|
validReq(func(r *ateapipb.DeleteAtespaceRequest) { r.Atespace.Atespace = "as" }),
|
|
field.ErrorList{field.Forbidden(field.NewPath("atespace", "atespace"), "")},
|
|
}, {
|
|
"missing atespace.name",
|
|
validReq(func(r *ateapipb.DeleteAtespaceRequest) { r.Atespace.Name = "" }),
|
|
field.ErrorList{field.Required(field.NewPath("atespace", "name"), "")},
|
|
}, {
|
|
"invalid atespace.name",
|
|
validReq(func(r *ateapipb.DeleteAtespaceRequest) { r.Atespace.Name = "invalid value" }),
|
|
field.ErrorList{field.Invalid(field.NewPath("atespace", "name"), nil, "").WithOrigin("format=k8s-short-name")},
|
|
}}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assertValidateErr(t, validateDeleteAtespaceRequest(context.Background(), tt.req), tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// validAtespace returns a minimal Atespace which should pass input validation.
|
|
func validAtespace(mods ...func(*ateapipb.Atespace)) *ateapipb.Atespace {
|
|
a := &ateapipb.Atespace{
|
|
Metadata: &ateapipb.ResourceMetadata{Name: "team1"},
|
|
}
|
|
for _, m := range mods {
|
|
m(a)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// withAtespaceMetadata returns a modifier func (see validAtespace) which sets
|
|
// the atespace's resource metadata to a valid value.
|
|
func withAtespaceMetadata(mutate func(*ateapipb.ResourceMetadata)) func(*ateapipb.Atespace) {
|
|
return func(a *ateapipb.Atespace) { mutate(a.Metadata) }
|
|
}
|