mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
499 lines
18 KiB
Go
499 lines
18 KiB
Go
package awsmodel
|
|
|
|
//test:in-package — checks generated inputs against the unexported model
|
|
//shapes, and the unexported pattern sampler and salt helpers.
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"maps"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var testRequestOptions = RequestOptions{AccountID: "123456789012", Region: "ap-southeast-2", AccessKeyID: "AKIAIOSFODNN7EXAMPLE"}
|
|
|
|
// constraintViolation is one model constraint an input breaks, found by
|
|
// walking the input against the model independently of the generator.
|
|
type constraintViolation struct {
|
|
path string
|
|
constraint Constraint
|
|
}
|
|
|
|
func (m *Model) inputViolations(shapeName, path string, value any, violations *[]constraintViolation) {
|
|
shape := m.shapes[shapeName]
|
|
add := func(constraint Constraint) {
|
|
*violations = append(*violations, constraintViolation{path: path, constraint: constraint})
|
|
}
|
|
outside := func(length float64) bool {
|
|
return (shape.Min != nil && length < *shape.Min) || (shape.Max != nil && length > *shape.Max)
|
|
}
|
|
switch shape.Type {
|
|
case "structure":
|
|
fields := value.(map[string]any)
|
|
for _, member := range shape.Required {
|
|
if _, ok := fields[member]; !ok {
|
|
*violations = append(*violations, constraintViolation{path: path + "." + member, constraint: ConstraintRequired})
|
|
}
|
|
}
|
|
for member, field := range fields {
|
|
m.inputViolations(shape.Members[member].Shape, path+"."+member, field, violations)
|
|
}
|
|
case "list":
|
|
items := value.([]any)
|
|
if outside(float64(len(items))) {
|
|
add(ConstraintLength)
|
|
}
|
|
for i, item := range items {
|
|
m.inputViolations(shape.Member.Shape, fmt.Sprintf("%s[%d]", path, i), item, violations)
|
|
}
|
|
case "map":
|
|
for key, entry := range value.(map[string]any) {
|
|
m.inputViolations(shape.Key.Shape, path+mapKeyPath, key, violations)
|
|
m.inputViolations(shape.Value.Shape, path+"."+key, entry, violations)
|
|
}
|
|
case "string":
|
|
text := value.(string)
|
|
if len(shape.Enum) > 0 {
|
|
if !slices.Contains(shape.Enum, text) {
|
|
add(ConstraintEnum)
|
|
}
|
|
return
|
|
}
|
|
if outside(float64(utf8.RuneCountInString(text))) {
|
|
add(ConstraintLength)
|
|
}
|
|
if shape.Pattern != "" {
|
|
if !testPattern(shape.Pattern).MatchString(text) {
|
|
add(ConstraintPattern)
|
|
}
|
|
}
|
|
case "integer", "long":
|
|
if outside(float64(value.(int64))) {
|
|
add(ConstraintRange)
|
|
}
|
|
case "float", "double":
|
|
if outside(value.(float64)) {
|
|
add(ConstraintRange)
|
|
}
|
|
case "blob":
|
|
if outside(float64(len(value.([]byte)))) {
|
|
add(ConstraintLength)
|
|
}
|
|
case "boolean":
|
|
_ = value.(bool)
|
|
case "timestamp":
|
|
_ = value.(time.Time)
|
|
}
|
|
}
|
|
|
|
var testPatterns sync.Map
|
|
|
|
func testPattern(pattern string) *regexp.Regexp {
|
|
if re, ok := testPatterns.Load(pattern); ok {
|
|
return re.(*regexp.Regexp)
|
|
}
|
|
re := regexp.MustCompile(re2Pattern(pattern))
|
|
testPatterns.Store(pattern, re)
|
|
return re
|
|
}
|
|
|
|
// TestGeneratedRequestsBreakOnlyTheirConstraint checks every modelled
|
|
// operation: acceptance cases come first and break nothing, a rejection breaks
|
|
// only its named constraint, and every case encodes.
|
|
func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) {
|
|
t.Parallel()
|
|
if testing.Short() {
|
|
t.Skip("generates requests for every modelled operation")
|
|
}
|
|
for _, service := range Services() {
|
|
model, err := Load(service)
|
|
require.NoError(t, err)
|
|
t.Run(string(service), func(t *testing.T) {
|
|
t.Parallel()
|
|
for _, operationName := range model.Operations() {
|
|
operation, _ := model.Operation(operationName)
|
|
if operation.Input == nil {
|
|
continue
|
|
}
|
|
plan, err := GenerateRequests(service, operationName, testRequestOptions)
|
|
require.NoError(t, err, operationName)
|
|
input := model.shapes[operation.Input.Shape]
|
|
rejecting := false
|
|
for _, request := range plan.Cases {
|
|
require.False(t, rejecting && request.Acceptance(), "%s acceptance %s follows a rejection", operationName, request.Path)
|
|
rejecting = !request.Acceptance()
|
|
if service != S3 {
|
|
_, err := EncodeRequest(service, operationName, request.Input)
|
|
require.NoError(t, err, "%s %s %s encodes", operationName, request.Constraint, request.Path)
|
|
}
|
|
var violations []constraintViolation
|
|
model.inputViolations(operation.Input.Shape, "$", request.Input, &violations)
|
|
for member := range request.Input {
|
|
if !slices.Contains(input.Required, member) && !slices.Contains(conditionallyRequired[service][operationName], member) {
|
|
require.Equal(t, request.Member, member, "%s %s %s carries an unrelated optional member", operationName, request.Constraint, request.Path)
|
|
}
|
|
}
|
|
if request.Acceptance() {
|
|
require.Empty(t, violations, "%s acceptance %s", operationName, request.Path)
|
|
continue
|
|
}
|
|
require.NotEmpty(t, violations, "%s %s %s breaks nothing", operationName, request.Constraint, request.Path)
|
|
for _, violation := range violations {
|
|
require.Equal(t, request.Path, violation.path, "%s %s %s also breaks %v", operationName, request.Constraint, request.Path, violation)
|
|
}
|
|
require.Contains(t, violations, constraintViolation{path: request.Path, constraint: request.Constraint}, operationName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestsIsolatesEachOptionalMember(t *testing.T) {
|
|
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
|
require.NoError(t, err)
|
|
|
|
acceptance := map[string]map[string]any{}
|
|
for _, request := range plan.Cases {
|
|
if request.Acceptance() {
|
|
acceptance[request.Member] = request.Input
|
|
}
|
|
}
|
|
require.ElementsMatch(t, []string{"AssumeRolePolicyDocument", "RoleName"}, slices.Collect(maps.Keys(acceptance[""])))
|
|
require.ElementsMatch(t, []string{"AssumeRolePolicyDocument", "RoleName", "Tags"}, slices.Collect(maps.Keys(acceptance["Tags"])))
|
|
require.ElementsMatch(t, []string{"", "Description", "MaxSessionDuration", "Path", "PermissionsBoundary", "Tags"}, slices.Collect(maps.Keys(acceptance)))
|
|
|
|
// Every case gets its own names, so one case cannot collide with what an
|
|
// earlier case created.
|
|
names := map[any]bool{}
|
|
for _, request := range plan.Cases {
|
|
if name, ok := request.Input["RoleName"]; ok && request.Path != "$.RoleName" {
|
|
require.False(t, names[name], "RoleName %v reused", name)
|
|
names[name] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestsSeedsConditionallyRequiredMembers(t *testing.T) {
|
|
for service, operations := range conditionallyRequired {
|
|
model, err := Load(service)
|
|
require.NoError(t, err)
|
|
for operationName, members := range operations {
|
|
operation, ok := model.Operation(operationName)
|
|
require.True(t, ok, "%s %s is not modelled", service, operationName)
|
|
input := model.shapes[operation.Input.Shape]
|
|
for _, member := range members {
|
|
require.Contains(t, input.Members, member, "%s %s", service, operationName)
|
|
require.NotContains(t, input.Required, member, "%s %s now models %s as required", service, operationName, member)
|
|
}
|
|
|
|
plan, err := GenerateRequests(service, operationName, testRequestOptions)
|
|
require.NoError(t, err)
|
|
for _, request := range plan.Cases {
|
|
for _, member := range members {
|
|
require.NotEqual(t, member, request.Member, "%s %s", service, operationName)
|
|
require.Contains(t, request.Input, member, "%s %s %s %s", service, operationName, request.Constraint, request.Path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
plan, err := GenerateRequests(RDS, "CreateDBInstance", testRequestOptions)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(20), plan.Cases[0].Input["AllocatedStorage"])
|
|
plan, err = GenerateRequests(EC2, "CreateCapacityReservation", testRequestOptions)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ap-southeast-2a", plan.Cases[0].Input["AvailabilityZone"])
|
|
|
|
// A constraint inside a seeded member is judged against the base request.
|
|
plan, err = GenerateRequests(ECS, "CreateCapacityProvider", testRequestOptions)
|
|
require.NoError(t, err)
|
|
nested := 0
|
|
for _, request := range plan.Cases {
|
|
if strings.HasPrefix(request.Path, "$.autoScalingGroupProvider.") {
|
|
nested++
|
|
require.Empty(t, request.Member, request.Path)
|
|
}
|
|
}
|
|
require.NotZero(t, nested)
|
|
}
|
|
|
|
func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) {
|
|
options := testRequestOptions
|
|
options.Fixtures = map[string]string{"RoleName": "fixture"}
|
|
for operation, wantFixture := range map[string]bool{"TagRole": true, "CreateRole": false, "DeleteRole": false} {
|
|
plan, err := GenerateRequests(IAM, operation, options)
|
|
require.NoError(t, err)
|
|
for _, request := range plan.Cases {
|
|
if request.Path == "$.RoleName" {
|
|
continue
|
|
}
|
|
require.Equal(t, wantFixture, request.Input["RoleName"] == "fixture", "%s %s %s", operation, request.Constraint, request.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestsAttributesRejections(t *testing.T) {
|
|
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
|
require.NoError(t, err)
|
|
|
|
byPath := map[string]RequestCase{}
|
|
for _, request := range plan.Cases {
|
|
if !request.Acceptance() {
|
|
byPath[string(request.Constraint)+" "+request.Path] = request
|
|
}
|
|
}
|
|
// A broken required member is judged against the required-only request;
|
|
// a broken optional one against the request that adds just that member.
|
|
require.Empty(t, byPath["required $.RoleName"].Member)
|
|
require.Equal(t, "MaxSessionDuration", byPath["range $.MaxSessionDuration"].Member)
|
|
require.Equal(t, "Tags", byPath["required $.Tags[0].Key"].Member)
|
|
}
|
|
|
|
func TestGenerateRequestsBreaksEachConstraintKind(t *testing.T) {
|
|
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
|
require.NoError(t, err)
|
|
var rejections []string
|
|
for _, request := range plan.Cases {
|
|
if !request.Acceptance() {
|
|
rejections = append(rejections, string(request.Constraint)+" "+request.Path)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"required $.RoleName", "length $.RoleName", "pattern $.RoleName",
|
|
"range $.MaxSessionDuration", "length $.Tags", "required $.Tags[0].Key",
|
|
} {
|
|
require.Contains(t, rejections, want)
|
|
}
|
|
|
|
plan, err = GenerateRequests(EC2, "CreateVolume", testRequestOptions)
|
|
require.NoError(t, err)
|
|
var enums []string
|
|
for _, request := range plan.Cases {
|
|
if request.Constraint == ConstraintEnum {
|
|
enums = append(enums, request.Path)
|
|
}
|
|
}
|
|
require.Contains(t, enums, "$.VolumeType")
|
|
}
|
|
|
|
func TestGenerateRequestsBreaksMapKeys(t *testing.T) {
|
|
plan, err := GenerateRequests(EKS, "TagResource", testRequestOptions)
|
|
require.NoError(t, err)
|
|
var keyRejections []Constraint
|
|
for _, request := range plan.Cases {
|
|
if request.Path == "$.tags"+mapKeyPath {
|
|
keyRejections = append(keyRejections, request.Constraint)
|
|
tags := request.Input["tags"].(map[string]any)
|
|
require.Len(t, tags, 1, "the key is renamed, not added")
|
|
}
|
|
}
|
|
require.Equal(t, []Constraint{ConstraintLength, ConstraintLength}, keyRejections, "below min and above max")
|
|
}
|
|
|
|
func TestGenerateRequestsRecordsUnbrokenConstraints(t *testing.T) {
|
|
plan, err := GenerateRequests(EKS, "DescribeCluster", testRequestOptions)
|
|
require.NoError(t, err)
|
|
require.Contains(t, plan.Unbroken, Skip{Path: "$.name", Reason: "a required URI label cannot be omitted"})
|
|
|
|
plan, err = GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
|
require.NoError(t, err)
|
|
require.Contains(t, plan.Unbroken, Skip{Path: "$.AssumeRolePolicyDocument", Reason: "max length 131072 is above the generated limit"})
|
|
require.Contains(t, plan.DeclaredErrors, "InvalidInput")
|
|
}
|
|
|
|
func TestGenerateRequestsSkipsAcceptanceForUnguessableMembers(t *testing.T) {
|
|
plan, err := GenerateRequests(IAM, "ListRoles", testRequestOptions)
|
|
require.NoError(t, err)
|
|
var acceptance, markerRejections []string
|
|
for _, request := range plan.Cases {
|
|
if request.Acceptance() {
|
|
acceptance = append(acceptance, request.Member)
|
|
} else if request.Member == "Marker" {
|
|
markerRejections = append(markerRejections, string(request.Constraint))
|
|
}
|
|
}
|
|
require.ElementsMatch(t, []string{"", "MaxItems", "PathPrefix"}, acceptance)
|
|
require.NotEmpty(t, markerRejections, "a pagination token still gets rejection requests")
|
|
}
|
|
|
|
func TestGenerateRequestsUsesResourceHints(t *testing.T) {
|
|
tests := []struct {
|
|
service Service
|
|
operation string
|
|
member string
|
|
want *regexp.Regexp
|
|
}{
|
|
{EC2, "TerminateInstances", "InstanceIds", regexp.MustCompile(`^i-[0-9a-f]{17}$`)},
|
|
{EC2, "DeleteSecurityGroup", "GroupId", regexp.MustCompile(`^sg-[0-9a-f]{17}$`)},
|
|
{ElasticLoadBalancingV2, "DeleteLoadBalancer", "LoadBalancerArn", regexp.MustCompile(`^arn:aws:elasticloadbalancing:ap-southeast-2:123456789012:loadbalancer/app/`)},
|
|
{IAM, "AttachRolePolicy", "PolicyArn", regexp.MustCompile(`^arn:aws:iam::123456789012:policy/`)},
|
|
{RDS, "CreateDBInstance", "Engine", regexp.MustCompile(`^postgres$`)},
|
|
{ECR, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:ecr:ap-southeast-2:123456789012:repository/`)},
|
|
{EKS, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:eks:ap-southeast-2:123456789012:cluster/`)},
|
|
{EKS, "AssociateAccessPolicy", "policyArn", regexp.MustCompile(`^arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy$`)},
|
|
{EKS, "CreateAddon", "addonName", regexp.MustCompile(`^aws-ebs-csi-driver$`)},
|
|
{EKS, "CreateAccessEntry", "type", regexp.MustCompile(`^STANDARD$`)},
|
|
{ElasticLoadBalancingV2, "CreateTargetGroup", "ProtocolVersion", regexp.MustCompile(`^HTTP1$`)},
|
|
{EC2, "CopyImage", "SourceRegion", regexp.MustCompile(`^ap-southeast-2$`)},
|
|
{STS, "GetAccessKeyInfo", "AccessKeyId", regexp.MustCompile(`^AKIAIOSFODNN7EXAMPLE$`)},
|
|
{IAM, "DeleteAccessKey", "AccessKeyId", regexp.MustCompile(`^AKIA[0-9A-F]{16}$`)},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.operation+"."+test.member, func(t *testing.T) {
|
|
plan, err := GenerateRequests(test.service, test.operation, testRequestOptions)
|
|
require.NoError(t, err)
|
|
for _, request := range plan.Cases {
|
|
if !request.Acceptance() {
|
|
continue
|
|
}
|
|
value, ok := request.Input[test.member]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if values, isList := value.([]any); isList {
|
|
value = values[0]
|
|
}
|
|
require.Regexp(t, test.want, value)
|
|
return
|
|
}
|
|
t.Fatalf("no acceptance request sets %s", test.member)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestsHintsNestedMembers(t *testing.T) {
|
|
plan, err := GenerateRequests(ElasticLoadBalancingV2, "CreateTargetGroup", testRequestOptions)
|
|
require.NoError(t, err)
|
|
for _, request := range plan.Cases {
|
|
if request.Acceptance() && request.Member == "Matcher" {
|
|
require.Equal(t, "200", request.Input["Matcher"].(map[string]any)["HttpCode"])
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("no acceptance request sets Matcher")
|
|
}
|
|
|
|
// AWS refuses a tag resource type the operation does not create, so a valid
|
|
// request tags the operation's own type rather than the enum's first value.
|
|
func TestGenerateRequestsTagsTheOperationsOwnResourceType(t *testing.T) {
|
|
for operation, want := range map[string]string{"CreateVpc": "vpc", "CreateLaunchTemplate": "launch-template"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
plan, err := GenerateRequests(EC2, operation, testRequestOptions)
|
|
require.NoError(t, err)
|
|
for _, request := range plan.Cases {
|
|
if request.Acceptance() && request.Member == "TagSpecifications" {
|
|
spec := request.Input["TagSpecifications"].([]any)[0].(map[string]any)
|
|
require.Equal(t, want, spec["ResourceType"])
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("no acceptance request sets TagSpecifications")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestsImportsAVerifiableCertificate(t *testing.T) {
|
|
plan, err := GenerateRequests(ACM, "ImportCertificate", testRequestOptions)
|
|
require.NoError(t, err)
|
|
var input map[string]any
|
|
for _, request := range plan.Cases {
|
|
if request.Acceptance() && request.Member == "CertificateChain" {
|
|
input = request.Input
|
|
}
|
|
}
|
|
require.NotNil(t, input, "no acceptance request sets CertificateChain")
|
|
|
|
pair, err := tls.X509KeyPair(input["Certificate"].([]byte), input["PrivateKey"].([]byte))
|
|
require.NoError(t, err)
|
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
|
require.NoError(t, err)
|
|
roots := x509.NewCertPool()
|
|
require.True(t, roots.AppendCertsFromPEM(input["CertificateChain"].([]byte)))
|
|
_, err = leaf.Verify(x509.VerifyOptions{Roots: roots, DNSName: "spx.example.com"})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestRE2EquivalentsMatchTheModelledSemantics(t *testing.T) {
|
|
modelled := map[string]bool{}
|
|
for _, service := range Services() {
|
|
model, err := Load(service)
|
|
require.NoError(t, err)
|
|
for _, shape := range model.shapes {
|
|
modelled[shape.Pattern] = true
|
|
}
|
|
}
|
|
for pattern := range re2Equivalents {
|
|
require.True(t, modelled[pattern], "no model uses %s", pattern)
|
|
}
|
|
|
|
domain := testPattern(`^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`)
|
|
for _, name := range []string{"example.com", "*.example.com", "a.b.co", "x-y.example.com", "a." + strings.Repeat("b", 63)} {
|
|
require.True(t, domain.MatchString(name), name)
|
|
}
|
|
for _, name := range []string{"example", "-a.com", "a-.com", "a.c", "a.-com", "a.com-", "a..com", "*.com.*", strings.Repeat("a", 64) + ".com"} {
|
|
require.False(t, domain.MatchString(name), name)
|
|
}
|
|
}
|
|
|
|
func TestPatternSamplerMatchesWholeValue(t *testing.T) {
|
|
tests := []struct {
|
|
pattern string
|
|
min, max int
|
|
}{
|
|
{`^[\w+=,.@-]+$`, 1, 64},
|
|
{`(/)|(/[!-~]+/)`, 1, 512},
|
|
{`[a-z]{3}-[0-9]{4}`, 8, 8},
|
|
{`^arn:aws:iam::\d{12}:role/[a-zA-Z0-9]+$`, 20, 2048},
|
|
{`(Tcp|Udp)`, 3, 3},
|
|
{`[\p{L}\p{Z}\p{N}_.:/=+\-@]*`, 0, 128},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.pattern, func(t *testing.T) {
|
|
sampler, err := newPatternSampler(test.pattern)
|
|
require.NoError(t, err)
|
|
value, ok := sampler.sample(test.min, test.max)
|
|
require.True(t, ok)
|
|
require.True(t, sampler.fullMatch(value), "%q does not fully match", value)
|
|
length := utf8.RuneCountInString(value)
|
|
require.GreaterOrEqual(t, length, test.min)
|
|
require.LessOrEqual(t, length, test.max)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPatternSamplerRejectsLookahead(t *testing.T) {
|
|
_, err := newPatternSampler(`^(?!aws:).*`)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestPatternBreakerAvoidsUnanchoredMatches(t *testing.T) {
|
|
// Unanchored, the pattern matches any value with one letter in it.
|
|
re := regexp.MustCompile(`[a-z]`)
|
|
broken, ok := patternBreaker(re, 3, 10)
|
|
require.True(t, ok)
|
|
require.False(t, re.MatchString(broken), "%q", broken)
|
|
require.GreaterOrEqual(t, len(broken), 3)
|
|
|
|
_, ok = patternBreaker(regexp.MustCompile(`.*`), 1, 10)
|
|
require.False(t, ok, "nothing breaks a pattern that matches everything")
|
|
}
|
|
|
|
func TestSaltLettersAreDistinct(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for n := range 2000 {
|
|
salt := saltLetters(n)
|
|
require.False(t, seen[salt], "salt %d repeats %q", n, salt)
|
|
require.Equal(t, strings.ToLower(salt), salt)
|
|
seen[salt] = true
|
|
}
|
|
}
|