package awsinv import ( "context" "errors" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/iam" iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" "github.com/aws/aws-sdk-go-v2/service/route53" r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/carlosframework/platform/internal/infraview" ) type fakeTagging struct { out *resourcegroupstaggingapi.GetResourcesOutput err error } func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { return f.out, f.err } // A resource the typed mappings already produced must NOT appear twice // because the catch-all also saw it; anything the catch-all alone knows // about must appear as "other" rather than vanishing. This covers all // three global services (S3, Route53, IAM) as well as EC2, because the // tagging catch-all runs once per region and a global service's resources // surface through whichever region happens to be home for it — a bucket // in its own region, a hosted zone or an IAM role through us-east-1 — so a // test that wires only EC2 would never exercise the global/regional // ordering the catch-all's dedup depends on. func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { factory := func(region string) Clients { return Clients{ EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ InstanceId: aws.String("i-123"), }}}}, }}, S3: &fakeS3{ buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, }, Route53: &fakeRoute53{ zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, }}, recs: &route53.ListResourceRecordSetsOutput{}, }, IAM: &fakeIAM{ roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ {RoleName: aws.String("region-host"), Arn: aws.String("arn:aws:iam::1234:role/region-host")}, }}, users: &iam.ListUsersOutput{}, attached: &iam.ListAttachedRolePoliciesOutput{}, }, Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ ResourceTagMappingList: []rgtypes.ResourceTagMapping{ {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")}, {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")}, {ResourceARN: aws.String("arn:aws:s3:::carlos-data-ie")}, {ResourceARN: aws.String("arn:aws:route53:::hostedzone/Z1")}, {ResourceARN: aws.String("arn:aws:iam::1234:role/region-host")}, }, }}, } } p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") inv, err := p.Inventory(context.Background()) if err != nil { t.Fatalf("Inventory: %v", err) } machines := inv.ByKind(infraview.KindMachine) if len(machines) != 1 { t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines)) } buckets := inv.ByKind(infraview.KindBucket) if len(buckets) != 1 { t.Fatalf("buckets = %d, want 1 (the catch-all must not duplicate the S3 mapping)", len(buckets)) } zones := inv.ByKind(infraview.KindDNSZone) if len(zones) != 1 { t.Fatalf("zones = %d, want 1 (the catch-all must not duplicate the Route53 mapping)", len(zones)) } identities := inv.ByKind(infraview.KindIdentity) if len(identities) != 1 { t.Fatalf("identities = %d, want 1 (the catch-all must not duplicate the IAM mapping)", len(identities)) } others := inv.ByKind(infraview.KindOther) if len(others) != 1 || others[0].NativeType != "sqs" { t.Fatalf("others = %+v, want one sqs resource", others) } if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() { t.Fatalf("header not stamped: %+v", inv) } } // Auth-shaped failure with nothing discovered is the one case that must // reach the caller as an error — it is what latches the console's pass off // so an ungranted deployment says "not granted" instead of erroring hourly. func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) { authErr := &fakeAPIErr{code: "AccessDenied"} factory := func(region string) Clients { return Clients{ EC2: &fakeEC2Failing{err: authErr}, Tagging: &fakeTagging{err: authErr}, } } p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") _, err := p.Inventory(context.Background()) if err == nil { t.Fatal("want an error when every read is denied and nothing was discovered") } if !errors.Is(err, ErrNoGrant) { t.Fatalf("err = %v, want it to satisfy errors.Is(err, ErrNoGrant) — this is what the console's latch matches on", err) } } // IsAuthShaped must also recognize ErrNoGrant itself, not just a denied // smithy code — Inventory's assembly-time denial (this test's own sibling, // TestAuthShapedWithNoResourcesIsAnError) has no surviving smithy.APIError // to classify by the time it reaches the caller, only this sentinel. func TestIsAuthShapedRecognizesErrNoGrant(t *testing.T) { if !IsAuthShaped(ErrNoGrant) { t.Fatal("IsAuthShaped(ErrNoGrant) = false, want true") } if IsAuthShaped(errors.New("some other error")) { t.Fatal("IsAuthShaped(plain error) = true, want false") } } // The same denial, but something WAS discovered: the inventory is returned // with the failures named in Partial, never thrown away. func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) { factory := func(region string) Clients { return Clients{ EC2: &fakeEC2{ instances: &ec2.DescribeInstancesOutput{ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ InstanceId: aws.String("i-123"), }}}}, }, errVols: errors.New("AccessDenied: not authorized"), }, Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")}, } } p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") inv, err := p.Inventory(context.Background()) if err != nil { t.Fatalf("Inventory: %v", err) } if len(inv.Partial) == 0 { t.Fatal("failures must be named in Partial") } } // A tagged resource that happens to share its trailing ARN component with // an unrelated typed resource's name must NOT be swallowed by dedup: only // a literal Resource.ID match may suppress a catch-all entry. Before this // fix, add() also seeded the bare trailing component of every resource's // ID into seen, so an IAM role named "worker" silently dropped an SQS // queue also named "worker" — a common naming collision, not a rare one. func TestCatchAllSurvivesTrailingNameCollision(t *testing.T) { factory := func(region string) Clients { return Clients{ IAM: &fakeIAM{ roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ {RoleName: aws.String("worker"), Arn: aws.String("arn:aws:iam::1234:role/worker")}, }}, users: &iam.ListUsersOutput{}, attached: &iam.ListAttachedRolePoliciesOutput{}, }, Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ ResourceTagMappingList: []rgtypes.ResourceTagMapping{ {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:worker")}, }, }}, } } p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") inv, err := p.Inventory(context.Background()) if err != nil { t.Fatalf("Inventory: %v", err) } identities := inv.ByKind(infraview.KindIdentity) if len(identities) != 1 { t.Fatalf("identities = %d, want 1 (the IAM role)", len(identities)) } others := inv.ByKind(infraview.KindOther) if len(others) != 1 || others[0].NativeType != "sqs" { t.Fatalf("others = %+v, want the SQS queue to survive sharing the trailing name %q with the IAM role", others, "worker") } }