package main import ( "context" "fmt" "io" "net/http" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" sqtypes "github.com/aws/aws-sdk-go-v2/service/servicequotas/types" "github.com/google/go-cmp/cmp" "github.com/upbound/function-aws-query/input/v1beta1" "google.golang.org/protobuf/types/known/structpb" "github.com/crossplane/function-sdk-go/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/resource" ) // respStub is an aws.HTTPClient that returns canned responses in sequence (the // last one repeats), so the real AWS SDK marshals the request and unmarshals our // response — exercising the handlers' projection and pagination for real. type respStub struct { bodies []string contentType string calls int // requests records each marshalled request body, so a test can assert what // was actually sent (e.g. that a filter went server-side). requests []string } func (s *respStub) Do(req *http.Request) (*http.Response, error) { if req.Body != nil { sent, err := io.ReadAll(req.Body) if err != nil { return nil, err } s.requests = append(s.requests, string(sent)) } body := s.bodies[s.calls] if s.calls < len(s.bodies)-1 { s.calls++ } h := http.Header{} if s.contentType != "" { h.Set("Content-Type", s.contentType) } return &http.Response{ StatusCode: http.StatusOK, Header: h, Body: io.NopCloser(strings.NewReader(body)), }, nil } func stubCfg(rt *respStub) aws.Config { return aws.Config{ Region: "eu-central-1", Credentials: credentials.NewStaticCredentialsProvider("AKID", "SECRET", ""), HTTPClient: rt, RetryMaxAttempts: 1, } } func newQuery() *AWSQuery { return &AWSQuery{log: logging.NewNopLogger()} } // --- pure helpers ----------------------------------------------------------- func TestToEC2Filters(t *testing.T) { got := toEC2Filters([]v1beta1.Filter{{Name: "state", Values: []string{"available"}}}) if len(got) != 1 || aws.ToString(got[0].Name) != "state" || len(got[0].Values) != 1 || got[0].Values[0] != "available" { t.Errorf("toEC2Filters wrong result: %+v", got) } if toEC2Filters(nil) != nil { t.Error("toEC2Filters(nil) should be nil") } } func TestToTagFilters(t *testing.T) { got := toTagFilters([]v1beta1.Filter{{Name: "Environment", Values: []string{"prod"}}}) if len(got) != 1 || aws.ToString(got[0].Key) != "Environment" || got[0].Values[0] != "prod" { t.Errorf("toTagFilters wrong result: %+v", got) } if toTagFilters(nil) != nil { t.Error("toTagFilters(nil) should be nil") } } func TestToSTSTags(t *testing.T) { got := toSTSTags(map[string]string{"a": "1", "b": "2"}) if len(got) != 2 { t.Fatalf("expected 2 tags, got %d", len(got)) } seen := map[string]string{} for _, tg := range got { seen[aws.ToString(tg.Key)] = aws.ToString(tg.Value) } if seen["a"] != "1" || seen["b"] != "2" { t.Errorf("toSTSTags wrong result: %+v", seen) } if toSTSTags(nil) != nil { t.Error("toSTSTags(nil) should be nil") } } func TestQuotaToMap(t *testing.T) { got := quotaToMap(sqtypes.ServiceQuota{ QuotaCode: aws.String("L-1"), QuotaName: aws.String("VPCs"), Unit: aws.String("None"), Adjustable: true, GlobalQuota: false, Value: aws.Float64(5), }) want := map[string]any{ "quotaCode": "L-1", "quotaName": "VPCs", "unit": "None", "adjustable": true, "globalQuota": false, "value": float64(5), } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("quotaToMap: -want +got:\n%s", diff) } // Value omitted when nil. if _, ok := quotaToMap(sqtypes.ServiceQuota{QuotaCode: aws.String("L-2")})["value"]; ok { t.Error("value should be omitted when nil") } } func TestIsGlobalQuery(t *testing.T) { for _, q := range []string{"GetCallerIdentity", "DescribeRegions"} { if !isGlobalQuery(q) { t.Errorf("%s should be global", q) } } if isGlobalQuery("DescribeVpcs") { t.Error("DescribeVpcs should not be global") } } func TestIniRegion(t *testing.T) { if got := iniRegion([]byte("[default]\nregion = eu-west-1\n")); got != "eu-west-1" { t.Errorf("iniRegion = %q, want eu-west-1", got) } if got := iniRegion(nil); got != "" { t.Errorf("iniRegion(nil) = %q, want empty", got) } } func TestResolveRegion(t *testing.T) { creds := map[string][]byte{"credentials": []byte("[default]\nregion = eu-west-1\n")} cases := map[string]struct { creds map[string][]byte in *v1beta1.Input want string }{ "InputRegionWins": {creds: creds, in: &v1beta1.Input{Region: aws.String("ap-south-1"), QueryType: "DescribeVpcs"}, want: "ap-south-1"}, "INIRegion": {creds: creds, in: &v1beta1.Input{QueryType: "DescribeVpcs"}, want: "eu-west-1"}, "GlobalFallback": {creds: map[string][]byte{}, in: &v1beta1.Input{QueryType: "DescribeRegions"}, want: "us-east-1"}, "EmptyNonGlobal": {creds: map[string][]byte{}, in: &v1beta1.Input{QueryType: "DescribeVpcs"}, want: ""}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { if got := resolveRegion(tc.creds, tc.in); got != tc.want { t.Errorf("resolveRegion = %q, want %q", got, tc.want) } }) } } func TestWebIdentityTokenRetriever(t *testing.T) { t.Run("SecretEmpty", func(t *testing.T) { if _, err := webIdentityTokenRetriever(nil, &v1beta1.WebIdentity{RoleARN: "r"}); err == nil { t.Error("expected error for empty token secret") } }) t.Run("SecretOK", func(t *testing.T) { r, err := webIdentityTokenRetriever([]byte("jwt"), &v1beta1.WebIdentity{RoleARN: "r"}) if err != nil { t.Fatalf("unexpected error: %v", err) } tok, _ := r.GetIdentityToken() if string(tok) != "jwt" { t.Errorf("token = %q, want jwt", tok) } }) t.Run("FilesystemNoPath", func(t *testing.T) { if _, err := webIdentityTokenRetriever(nil, &v1beta1.WebIdentity{RoleARN: "r", TokenConfig: &v1beta1.TokenConfig{Source: "Filesystem"}}); err == nil { t.Error("expected error for missing fsPath") } }) t.Run("FilesystemOK", func(t *testing.T) { if _, err := webIdentityTokenRetriever(nil, &v1beta1.WebIdentity{RoleARN: "r", TokenConfig: &v1beta1.TokenConfig{Source: "Filesystem", FSPath: aws.String("/tmp/token")}}); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("Unsupported", func(t *testing.T) { if _, err := webIdentityTokenRetriever(nil, &v1beta1.WebIdentity{RoleARN: "r", TokenConfig: &v1beta1.TokenConfig{Source: "Bogus"}}); err == nil { t.Error("expected error for unsupported source") } }) } // --- buildAWSConfig --------------------------------------------------------- func TestBuildAWSConfig(t *testing.T) { iniCreds := map[string][]byte{"credentials": []byte("[default]\naws_access_key_id = AK\naws_secret_access_key = SK\nregion = eu-west-1\n")} t.Run("SecretResolvesCredsAndRegion", func(t *testing.T) { cfg, err := buildAWSConfig(context.Background(), iniCreds, &v1beta1.Input{QueryType: "DescribeVpcs"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Region != "eu-west-1" { t.Errorf("region = %q, want eu-west-1", cfg.Region) } got, err := cfg.Credentials.Retrieve(context.Background()) if err != nil { t.Fatalf("retrieve: %v", err) } if got.AccessKeyID != "AK" || got.SecretAccessKey != "SK" { t.Errorf("creds = %+v, want AK/SK", got) } }) t.Run("SecretEmptyErrors", func(t *testing.T) { if _, err := buildAWSConfig(context.Background(), map[string][]byte{}, &v1beta1.Input{QueryType: "DescribeVpcs"}); err == nil { t.Error("expected error for missing credentials") } }) t.Run("WebIdentityRequiresRoleARN", func(t *testing.T) { in := &v1beta1.Input{QueryType: "DescribeRegions", Identity: &v1beta1.Identity{Source: v1beta1.IdentitySourceWebIdentity}} if _, err := buildAWSConfig(context.Background(), map[string][]byte{}, in); err == nil { t.Error("expected error for missing roleARN") } }) t.Run("UnsupportedSource", func(t *testing.T) { in := &v1beta1.Input{QueryType: "DescribeRegions", Identity: &v1beta1.Identity{Source: "Bogus"}} if _, err := buildAWSConfig(context.Background(), map[string][]byte{}, in); err == nil { t.Error("expected error for unsupported source") } }) t.Run("IRSANoSecret", func(t *testing.T) { in := &v1beta1.Input{QueryType: "DescribeRegions", Identity: &v1beta1.Identity{Source: v1beta1.IdentitySourceIRSA}} cfg, err := buildAWSConfig(context.Background(), map[string][]byte{}, in) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Region != "us-east-1" { t.Errorf("region = %q, want us-east-1 (global fallback)", cfg.Region) } }) t.Run("AssumeRoleChainWires", func(t *testing.T) { in := &v1beta1.Input{QueryType: "DescribeVpcs", Identity: &v1beta1.Identity{ Source: v1beta1.IdentitySourceSecret, AssumeRoleChain: []v1beta1.AssumeRole{{RoleARN: "arn:aws:iam::222:role/x"}}, }} cfg, err := buildAWSConfig(context.Background(), iniCreds, in) if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Credentials == nil { t.Error("expected credentials provider to be set by the chain") } }) } // --- resolveRegionRef ------------------------------------------------------- func TestResolveRegionRef(t *testing.T) { f := &Function{log: logging.NewNopLogger()} t.Run("Spec", func(t *testing.T) { req := &fnv1.RunFunctionRequest{ Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON( `{"apiVersion":"example.io/v1alpha1","kind":"XAccount","metadata":{"name":"x"},"spec":{"region":"ap-south-1"}}`)}}, } in := &v1beta1.Input{RegionRef: aws.String("spec.region")} if err := f.resolveRegionRef(req, in, &fnv1.RunFunctionResponse{}); err != nil { t.Fatalf("unexpected error: %v", err) } if aws.ToString(in.Region) != "ap-south-1" { t.Errorf("region = %q, want ap-south-1", aws.ToString(in.Region)) } }) t.Run("Status", func(t *testing.T) { req := &fnv1.RunFunctionRequest{ Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON( `{"apiVersion":"example.io/v1alpha1","kind":"XAccount","metadata":{"name":"x"},"status":{"chosenRegion":"sa-east-1"}}`)}}, } in := &v1beta1.Input{RegionRef: aws.String("status.chosenRegion")} if err := f.resolveRegionRef(req, in, &fnv1.RunFunctionResponse{}); err != nil { t.Fatalf("unexpected error: %v", err) } if aws.ToString(in.Region) != "sa-east-1" { t.Errorf("region = %q, want sa-east-1", aws.ToString(in.Region)) } }) t.Run("Unrecognized", func(t *testing.T) { in := &v1beta1.Input{RegionRef: aws.String("bogus.x")} if err := f.resolveRegionRef(&fnv1.RunFunctionRequest{}, in, &fnv1.RunFunctionResponse{}); err == nil { t.Error("expected error for unrecognized regionRef prefix") } }) t.Run("NoRef", func(t *testing.T) { in := &v1beta1.Input{} if err := f.resolveRegionRef(&fnv1.RunFunctionRequest{}, in, &fnv1.RunFunctionResponse{}); err != nil { t.Errorf("unexpected error: %v", err) } if in.Region != nil { t.Error("region should remain unset") } }) } // --- handler validation guards (no network) --------------------------------- func TestHandlerValidationGuards(t *testing.T) { q := newQuery() ctx := context.Background() noRegion := aws.Config{} // empty region triggers the region-required guard cases := map[string]func() (any, error){ "AZsNoRegion": func() (any, error) { return q.describeAvailabilityZones(ctx, noRegion, &v1beta1.Input{}) }, "ImagesNoRegion": func() (any, error) { return q.describeImages(ctx, noRegion, &v1beta1.Input{}) }, "QuotasNoRegion": func() (any, error) { return q.listServiceQuotas(ctx, noRegion, &v1beta1.Input{}) }, "GetQuotaNoRegion": func() (any, error) { return q.getServiceQuota(ctx, noRegion, &v1beta1.Input{}) }, "ListResNoRegion": func() (any, error) { return q.listResources(ctx, noRegion, &v1beta1.Input{}) }, "GetResNoRegion": func() (any, error) { return q.getResources(ctx, noRegion, &v1beta1.Input{}) }, "ImagesNoFilter": func() (any, error) { return q.describeImages(ctx, stubCfg(&respStub{}), &v1beta1.Input{}) }, "QuotasNoService": func() (any, error) { return q.listServiceQuotas(ctx, stubCfg(&respStub{}), &v1beta1.Input{}) }, "GetQuotaNoCodes": func() (any, error) { return q.getServiceQuota(ctx, stubCfg(&respStub{}), &v1beta1.Input{}) }, "ListResNoTypeName": func() (any, error) { return q.listResources(ctx, stubCfg(&respStub{}), &v1beta1.Input{}) }, "RouteTablesNoRegion": func() (any, error) { return q.describeRouteTables(ctx, noRegion, &v1beta1.Input{}) }, "SubnetsNoRegion": func() (any, error) { return q.describeSubnets(ctx, noRegion, &v1beta1.Input{}) }, "SGRulesNoRegion": func() (any, error) { return q.describeSecurityGroupRules(ctx, noRegion, &v1beta1.Input{}) }, "SubnetsNoFilters": func() (any, error) { return q.describeSubnets(ctx, stubCfg(&respStub{}), &v1beta1.Input{}) }, } for name, call := range cases { t.Run(name, func(t *testing.T) { if _, err := call(); err == nil { t.Error("expected a validation error, got nil") } }) } } // --- handlers with stubbed HTTP transport (projection + pagination) --------- func TestGetCallerIdentity(t *testing.T) { body := `` + `arn:aws:iam::123456789012:user/test` + `AIDEXAMPLE123456789012` + `req` got, err := newQuery().getCallerIdentity(context.Background(), stubCfg(&respStub{bodies: []string{body}, contentType: "text/xml"}), &v1beta1.Input{}) if err != nil { t.Fatalf("unexpected error: %v", err) } want := map[string]any{"account": "123456789012", "arn": "arn:aws:iam::123456789012:user/test", "userId": "AIDEXAMPLE"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } } func TestDescribeRegions(t *testing.T) { body := `r` + `us-east-1ec2.us-east-1.amazonaws.comopt-in-not-required` + `` got, err := newQuery().describeRegions(context.Background(), stubCfg(&respStub{bodies: []string{body}, contentType: "text/xml"}), &v1beta1.Input{}) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{map[string]any{"name": "us-east-1", "endpoint": "ec2.us-east-1.amazonaws.com", "optInStatus": "opt-in-not-required"}} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } } func TestListServiceQuotasPaginates(t *testing.T) { page1 := `{"NextToken":"n","Quotas":[{"QuotaCode":"L-1","QuotaName":"VPCs","Value":5.0,"Unit":"None","Adjustable":true,"GlobalQuota":false}]}` page2 := `{"Quotas":[{"QuotaCode":"L-2","QuotaName":"EIPs","Value":10.0,"Unit":"None","Adjustable":false,"GlobalQuota":false}]}` in := &v1beta1.Input{Parameters: map[string]string{"serviceCode": "ec2"}} got, err := newQuery().listServiceQuotas(context.Background(), stubCfg(&respStub{bodies: []string{page1, page2}, contentType: "application/x-amz-json-1.1"}), in) if err != nil { t.Fatalf("unexpected error: %v", err) } list, ok := got.([]any) if !ok || len(list) != 2 { t.Fatalf("expected 2 quotas across 2 pages, got %#v", got) } } func TestGetResourcesPaginates(t *testing.T) { page1 := `{"PaginationToken":"tok","ResourceTagMappingList":[{"ResourceARN":"arn:a","Tags":[{"Key":"Environment","Value":"prod"}]}]}` page2 := `{"PaginationToken":"","ResourceTagMappingList":[{"ResourceARN":"arn:b","Tags":[]}]}` in := &v1beta1.Input{Parameters: map[string]string{"resourceTypeFilters": "ec2:subnet"}} got, err := newQuery().getResources(context.Background(), stubCfg(&respStub{bodies: []string{page1, page2}, contentType: "application/x-amz-json-1.1"}), in) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{ map[string]any{"arn": "arn:a", "tags": map[string]any{"Environment": "prod"}}, map[string]any{"arn": "arn:b", "tags": map[string]any{}}, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } } // TestListResourcesFiltersClientSide uses hydrate=false so the client-side // filter runs against the list properties directly (no GetResource calls). func TestListResourcesFiltersClientSide(t *testing.T) { body := `{"TypeName":"AWS::EC2::VPC","ResourceDescriptions":[` + `{"Identifier":"vpc-1","Properties":"{\"VpcId\":\"vpc-1\",\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"prod\"}]}"},` + `{"Identifier":"vpc-2","Properties":"{\"VpcId\":\"vpc-2\",\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"dev\"}]}"}` + `]}` in := &v1beta1.Input{ Parameters: map[string]string{"typeName": "AWS::EC2::VPC", "hydrate": "false"}, Filters: []v1beta1.Filter{{Name: "tag:Environment", Values: []string{"prod"}}}, } got, err := newQuery().listResources(context.Background(), stubCfg(&respStub{bodies: []string{body}, contentType: "application/x-amz-json-1.0"}), in) if err != nil { t.Fatalf("unexpected error: %v", err) } list, ok := got.([]any) if !ok || len(list) != 1 { t.Fatalf("expected 1 VPC after client-side tag filter, got %#v", got) } m := list[0].(map[string]any) if m["identifier"] != "vpc-1" { t.Errorf("identifier = %v, want vpc-1", m["identifier"]) } props, ok := m["properties"].(map[string]any) if !ok || props["VpcId"] != "vpc-1" { t.Errorf("properties = %#v, want VpcId vpc-1", m["properties"]) } } // TestListResourcesHydrates covers the default path: ListResources returns only // the identifier, then GetResource hydrates the full model (incl. Tags), which // the client-side filter then matches. func TestListResourcesHydrates(t *testing.T) { listBody := `{"TypeName":"AWS::EC2::VPC","ResourceDescriptions":[{"Identifier":"vpc-1","Properties":"{\"VpcId\":\"vpc-1\"}"}]}` getBody := `{"TypeName":"AWS::EC2::VPC","ResourceDescription":{"Identifier":"vpc-1","Properties":"{\"VpcId\":\"vpc-1\",\"CidrBlock\":\"10.0.0.0/24\",\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"prod\"}]}"}}` in := &v1beta1.Input{ Parameters: map[string]string{"typeName": "AWS::EC2::VPC"}, // hydrate defaults to true Filters: []v1beta1.Filter{{Name: "tag:Environment", Values: []string{"prod"}}}, } // First HTTP call = ListResources, subsequent = GetResource (last repeats). stub := &respStub{bodies: []string{listBody, getBody}, contentType: "application/x-amz-json-1.0"} got, err := newQuery().listResources(context.Background(), stubCfg(stub), in) if err != nil { t.Fatalf("unexpected error: %v", err) } list, ok := got.([]any) if !ok || len(list) != 1 { t.Fatalf("expected 1 hydrated VPC, got %#v", got) } props := list[0].(map[string]any)["properties"].(map[string]any) if props["CidrBlock"] != "10.0.0.0/24" { t.Errorf("expected hydrated CidrBlock, got %#v", props) } } func TestDescribeImages(t *testing.T) { body := `r` + `ami-1ubuntu099720109477` + `2024-01-01T00:00:00.000Zx86_64` + `availableebsdesc` + `` in := &v1beta1.Input{Parameters: map[string]string{"owners": "099720109477"}} got, err := newQuery().describeImages(context.Background(), stubCfg(&respStub{bodies: []string{body}, contentType: "text/xml"}), in) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{map[string]any{ "imageId": "ami-1", "name": "ubuntu", "ownerId": "099720109477", "creationDate": "2024-01-01T00:00:00.000Z", "architecture": "x86_64", "state": "available", "rootDeviceType": "ebs", "description": "desc", }} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } } // --- Direct EC2 describes --------------------------------------------------- const ( routeTablesPage1 = `r` + `rtb-1vpc-1123456789012` + `rtbassoc-mainrtb-1` + `
true
associated
` + `10.0.0.0/16local` + `CreateRouteTableactive` + `Namemain
` + `tok
` routeTablesPage2 = `r` + `rtb-2vpc-1123456789012` + `rtbassoc-2rtb-2` + `subnet-1
false
associated` + `
` subnetsBody = `r` + `subnet-1` + `arn:aws:ec2:eu-central-1:123456789012:subnet/subnet-1` + `vpc-1123456789012eu-central-1a` + `euc1-az210.0.1.0/24available` + `falsetrue` + `250` + `Namepublic-a` securityGroupRulesBody = `r` + `sgr-1sg-1` + `arn:aws:ec2:eu-central-1:123456789012:security-group-rule/sgr-1` + `123456789012falsetcp` + `4434430.0.0.0/0https` + `Nameingress` + `sgr-2sg-1123456789012` + `true-1-1-1` + `sg-2` + `sgr-3sg-1123456789012` + `falseicmppl-1` + `` ) // ec2Input builds an input for one query type with a filter that query type // actually supports. This is not cosmetic: DescribeSecurityGroupRules accepts // only group-id, security-group-rule-id and tag:, and an unrecognised // filter NAME is fatal - so a shared "vpc-id" would put a request on the wire // that AWS rejects, while respStub's canned body made the suite pass anyway. func ec2Input(queryType string) *v1beta1.Input { name, value := "vpc-id", "vpc-1" if queryType == "DescribeSecurityGroupRules" { name, value = "group-id", "sg-1" } return &v1beta1.Input{Filters: []v1beta1.Filter{{Name: name, Values: []string{value}}}} } // TestEc2Dispatches proves each direct EC2 describe is registered under // its own queryType and reaches its own describe call. An unsupported value no // longer needs a runtime case: queryType is CRD-enum validated, so a typo is // rejected at admission instead of aborting a composition at reconcile. func TestEc2Dispatches(t *testing.T) { cases := map[string]string{ "DescribeRouteTables": routeTablesPage2, "DescribeSubnets": subnetsBody, "DescribeSecurityGroupRules": securityGroupRulesBody, } for queryType, body := range cases { t.Run(queryType, func(t *testing.T) { h := newQuery().registry()[queryType] if h == nil { t.Fatalf("%s is not wired into the handler registry", queryType) } got, err := h(context.Background(), stubCfg(&respStub{bodies: []string{body}, contentType: "text/xml"}), ec2Input(queryType)) if err != nil { t.Fatalf("unexpected error: %v", err) } list, ok := got.([]any) if !ok || len(list) == 0 { t.Fatalf("expected a non-empty list, got %#v", got) } }) } } // TestEc2FilterGuard pins the must-filter guard. These calls are // paginated and unbounded, so an empty filter set would page a whole region // into XR status. It is reachable without a typo: toFilters returns a non-nil // empty slice, so a filtersRef resolving to [] arrives with len 0. func TestEc2FilterGuard(t *testing.T) { for _, queryType := range []string{"DescribeRouteTables", "DescribeSubnets", "DescribeSecurityGroupRules"} { t.Run(queryType, func(t *testing.T) { h := newQuery().registry()[queryType] _, err := h(context.Background(), stubCfg(&respStub{}), &v1beta1.Input{}) if err == nil { t.Fatal("expected the filter guard to reject an empty filter set") } if !strings.Contains(err.Error(), "requires filters") { t.Errorf("expected the filter guard, got: %v", err) } }) } } // TestEc2RouteTablesPaginates covers the projection (associations, incl. // the main association ID) across two pages. func TestEc2RouteTablesPaginates(t *testing.T) { stub := &respStub{bodies: []string{routeTablesPage1, routeTablesPage2}, contentType: "text/xml"} got, err := newQuery().describeRouteTables(context.Background(), stubCfg(stub), ec2Input("DescribeRouteTables")) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{ map[string]any{ "routeTableId": "rtb-1", "vpcId": "vpc-1", "ownerId": "123456789012", "associations": []any{map[string]any{ "routeTableAssociationId": "rtbassoc-main", "routeTableId": "rtb-1", "subnetId": "", "gatewayId": "", "main": true, "state": "associated", }}, "routes": []any{map[string]any{ "destinationCidrBlock": "10.0.0.0/16", "destinationIpv6CidrBlock": "", "destinationPrefixListId": "", "carrierGatewayId": "", "coreNetworkArn": "", "egressOnlyInternetGatewayId": "", "gatewayId": "local", "instanceId": "", "localGatewayId": "", "natGatewayId": "", "networkInterfaceId": "", "transitGatewayId": "", "vpcPeeringConnectionId": "", "origin": "CreateRouteTable", "state": "active", }}, "tags": map[string]any{"Name": "main"}, }, map[string]any{ "routeTableId": "rtb-2", "vpcId": "vpc-1", "ownerId": "123456789012", "associations": []any{map[string]any{ "routeTableAssociationId": "rtbassoc-2", "routeTableId": "rtb-2", "subnetId": "subnet-1", "gatewayId": "", "main": false, "state": "associated", }}, "routes": []any{}, "tags": map[string]any{}, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } if len(stub.requests) != 2 { t.Fatalf("expected 2 requests (one per page), got %d", len(stub.requests)) } // vpc-id must go server-side - that is the whole point over ListResources. if len(stub.requests) == 0 { t.Fatal("no request recorded") } if !strings.Contains(stub.requests[0], "Filter.1.Name=vpc-id") { t.Errorf("vpc-id filter not sent server-side: %s", stub.requests[0]) } } // Isolates the region guard: the shared guards table only asserts err != nil, // which the SDK's endpoint-resolution error satisfies on its own. func TestEc2RegionGuard(t *testing.T) { _, err := newQuery().describeSubnets(context.Background(), aws.Config{}, ec2Input("DescribeSubnets")) if err == nil { t.Fatal("expected an error, got nil") } if !strings.Contains(err.Error(), "requires a region") { t.Errorf("expected the region guard, got: %v", err) } } func TestEc2Subnets(t *testing.T) { stub := &respStub{bodies: []string{subnetsBody}, contentType: "text/xml"} got, err := newQuery().describeSubnets(context.Background(), stubCfg(stub), ec2Input("DescribeSubnets")) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{map[string]any{ "subnetId": "subnet-1", "subnetArn": "arn:aws:ec2:eu-central-1:123456789012:subnet/subnet-1", "vpcId": "vpc-1", "ownerId": "123456789012", "availabilityZone": "eu-central-1a", "availabilityZoneId": "euc1-az2", "cidrBlock": "10.0.1.0/24", "state": "available", "defaultForAz": false, "mapPublicIpOnLaunch": true, "availableIpAddressCount": int64(250), // Always projected, so an IPv6-only subnet is distinguishable from a // projection failure. This fixture is IPv4-only, hence the empty set. "ipv6Native": false, "ipv6CidrBlockAssociationSet": []any{}, "tags": map[string]any{"Name": "public-a"}, }} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } if len(stub.requests) == 0 { t.Fatal("no request recorded") } if !strings.Contains(stub.requests[0], "Filter.1.Name=vpc-id") { t.Errorf("filter not sent server-side: %s", stub.requests[0]) } } // Optional keys: referencedGroupId only for group references, ports only when // on the wire - a live all-protocol rule reports -1/-1, not nothing. func TestEc2SecurityGroupRules(t *testing.T) { stub := &respStub{bodies: []string{securityGroupRulesBody}, contentType: "text/xml"} got, err := newQuery().describeSecurityGroupRules(context.Background(), stubCfg(stub), ec2Input("DescribeSecurityGroupRules")) if err != nil { t.Fatalf("unexpected error: %v", err) } want := []any{ map[string]any{ "securityGroupRuleId": "sgr-1", "securityGroupRuleArn": "arn:aws:ec2:eu-central-1:123456789012:security-group-rule/sgr-1", "groupId": "sg-1", "groupOwnerId": "123456789012", "isEgress": false, "ipProtocol": "tcp", "fromPort": int64(443), "toPort": int64(443), "cidrIpv4": "0.0.0.0/0", "cidrIpv6": "", "prefixListId": "", "description": "https", "tags": map[string]any{"Name": "ingress"}, }, map[string]any{ "securityGroupRuleId": "sgr-2", "securityGroupRuleArn": "", "groupId": "sg-1", "groupOwnerId": "123456789012", "isEgress": true, "ipProtocol": "-1", "fromPort": int64(-1), "toPort": int64(-1), "cidrIpv4": "", "cidrIpv6": "", "prefixListId": "", "description": "", "referencedGroupId": "sg-2", // Kept alongside the id so a cross-account reference is not // mistaken for a local group. Empty in this fixture. "referencedGroupUserId": "", "referencedGroupVpcId": "", "tags": map[string]any{}, }, map[string]any{ "securityGroupRuleId": "sgr-3", "securityGroupRuleArn": "", "groupId": "sg-1", "groupOwnerId": "123456789012", "isEgress": false, "ipProtocol": "icmp", "cidrIpv4": "", "cidrIpv6": "", "prefixListId": "pl-1", "description": "", "tags": map[string]any{}, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } if len(stub.requests) == 0 { t.Fatal("no request recorded") } if !strings.Contains(stub.requests[0], "Filter.1.Name=group-id") { t.Errorf("filter not sent server-side: %s", stub.requests[0]) } } func TestEc2TagsToMap(t *testing.T) { got := ec2TagsToMap([]ec2types.Tag{{Key: aws.String("Name"), Value: aws.String("x")}}) if diff := cmp.Diff(map[string]any{"Name": "x"}, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } if diff := cmp.Diff(map[string]any{}, ec2TagsToMap(nil)); diff != "" { t.Errorf("ec2TagsToMap(nil) should be an empty map:\n%s", diff) } } func TestPutInt32(t *testing.T) { m := map[string]any{} putInt32(m, "set", aws.Int32(7)) putInt32(m, "unset", nil) if diff := cmp.Diff(map[string]any{"set": int64(7)}, m); diff != "" { t.Errorf("-want +got:\n%s", diff) } } // --- dispatch + remaining skip paths ---------------------------------------- func TestAWSQueryUnsupportedType(t *testing.T) { creds := map[string][]byte{"credentials": []byte("[default]\naws_access_key_id = AK\naws_secret_access_key = SK\n")} if _, err := newQuery().awsQuery(context.Background(), creds, &v1beta1.Input{QueryType: "Nope"}); err == nil { t.Error("expected an error for an unsupported queryType") } } func TestRunFunctionSkipContextTarget(t *testing.T) { called := false f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, _ *v1beta1.Input) (any, error) { called = true return nil, nil }}} ctx, err := structpb.NewStruct(map[string]any{"existing": "data"}) if err != nil { t.Fatal(err) } req := &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "test"}, Input: resource.MustStructJSON(`{ "apiVersion":"aws.fn.crossplane.io/v1beta1","kind":"Input", "queryType":"DescribeRegions","target":"context.existing","skipQueryWhenTargetHasData":true }`), Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(observedXR)}}, Context: ctx, } rsp, err := f.RunFunction(context.Background(), req) if err != nil { t.Fatalf("unexpected err: %v", err) } if called { t.Error("awsQuery should not be called when the context target already has data") } if !hasCondition(rsp, "FunctionSkip") { t.Errorf("expected a FunctionSkip condition, got: %v", rsp.GetConditions()) } } func TestToFilters(t *testing.T) { t.Run("OK", func(t *testing.T) { v := []any{ map[string]any{"name": "tag:Env", "values": []any{"prod", "dev"}}, map[string]any{"name": "state", "values": []any{"available"}}, } got, err := toFilters(v) if err != nil { t.Fatal(err) } want := []v1beta1.Filter{ {Name: "tag:Env", Values: []string{"prod", "dev"}}, {Name: "state", Values: []string{"available"}}, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("-want +got:\n%s", diff) } }) t.Run("NotAList", func(t *testing.T) { if _, err := toFilters(map[string]any{}); err == nil { t.Error("expected error for non-list") } }) t.Run("MissingName", func(t *testing.T) { if _, err := toFilters([]any{map[string]any{"values": []any{"x"}}}); err == nil { t.Error("expected error for missing name") } }) t.Run("StringifiesValues", func(t *testing.T) { got, err := toFilters([]any{map[string]any{"name": "n", "values": []any{float64(1), true}}}) if err != nil { t.Fatal(err) } if got[0].Values[0] != "1" || got[0].Values[1] != "true" { t.Errorf("stringify failed: %v", got[0].Values) } }) } func TestToParameters(t *testing.T) { t.Run("OK", func(t *testing.T) { got, err := toParameters(map[string]any{"serviceCode": "ec2", "n": float64(5)}) if err != nil { t.Fatal(err) } if got["serviceCode"] != "ec2" || got["n"] != "5" { t.Errorf("got %v", got) } }) t.Run("NotAMap", func(t *testing.T) { if _, err := toParameters([]any{}); err == nil { t.Error("expected error for non-map") } }) } // TestRunFunctionResolvesRefs proves region/filters/parameters are resolved from // the XR spec (overriding static values) before the query runs. func TestRunFunctionResolvesRefs(t *testing.T) { var seen *v1beta1.Input f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, in *v1beta1.Input) (any, error) { seen = in return []any{}, nil }}} req := &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "test"}, Input: resource.MustStructJSON(`{ "apiVersion":"aws.fn.crossplane.io/v1beta1","kind":"Input", "queryType":"DescribeImages", "regionRef":"spec.region", "parameters":{"stale":"yes"}, "parametersRef":"spec.imageParams", "filters":[{"name":"stale","values":["x"]}], "filtersRef":"spec.imageFilters", "target":"status.amis" }`), Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(`{ "apiVersion":"example.io/v1alpha1","kind":"XAccount","metadata":{"name":"x"}, "spec":{"region":"eu-central-1","imageParams":{"owners":"099720109477"}, "imageFilters":[{"name":"name","values":["ubuntu-*"]}]} }`)}}, } if _, err := f.RunFunction(context.Background(), req); err != nil { t.Fatalf("unexpected err: %v", err) } if seen == nil { t.Fatal("awsQuery was not called") } if seen.Region == nil || *seen.Region != "eu-central-1" { t.Errorf("regionRef not resolved: %v", seen.Region) } if seen.Parameters["owners"] != "099720109477" { t.Errorf("parametersRef not resolved: %v", seen.Parameters) } if _, stale := seen.Parameters["stale"]; stale { t.Error("parametersRef should override static parameters") } if len(seen.Filters) != 1 || seen.Filters[0].Name != "name" || len(seen.Filters[0].Values) != 1 || seen.Filters[0].Values[0] != "ubuntu-*" { t.Errorf("filtersRef not resolved/override failed: %v", seen.Filters) } } func TestRunFunctionIntervalSkip(t *testing.T) { called := false f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, _ *v1beta1.Input) (any, error) { called = true return nil, nil }}} // A recent lastQueryTime within the interval must cause a skip. now := time.Now().Format(time.RFC3339) observed := fmt.Sprintf(`{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"test"},"status":{"data":[{"lastQueryTime":%q}]}}`, now) req := &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "test"}, Input: resource.MustStructJSON(`{ "apiVersion":"aws.fn.crossplane.io/v1beta1","kind":"Input", "queryType":"DescribeRegions","target":"status.data","queryIntervalMinutes":60 }`), Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(observed)}}, } rsp, err := f.RunFunction(context.Background(), req) if err != nil { t.Fatalf("unexpected err: %v", err) } if called { t.Error("awsQuery should not be called within the query interval") } if !hasCondition(rsp, "FunctionSkip") { t.Errorf("expected a FunctionSkip condition, got: %v", rsp.GetConditions()) } }