Files
spinifex/internal/awsmodel/request_test.go
T

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
}
}