| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/aws/aws-sdk-go-v2/aws" |
| 9 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 10 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 11 | "github.com/aws/aws-sdk-go-v2/service/iam" |
| 12 | iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| 13 | "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| 14 | rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" |
| 15 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 16 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 17 | "github.com/aws/aws-sdk-go-v2/service/s3" |
| 18 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| 19 | "github.com/carlosframework/platform/internal/infraview" |
| 20 | ) |
| 21 | |
| 22 | type fakeTagging struct { |
| 23 | out *resourcegroupstaggingapi.GetResourcesOutput |
| 24 | err error |
| 25 | } |
| 26 | |
| 27 | func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { |
| 28 | return f.out, f.err |
| 29 | } |
| 30 | |
| 31 | // A resource the typed mappings already produced must NOT appear twice |
| 32 | // because the catch-all also saw it; anything the catch-all alone knows |
| 33 | // about must appear as "other" rather than vanishing. This covers all |
| 34 | // three global services (S3, Route53, IAM) as well as EC2, because the |
| 35 | // tagging catch-all runs once per region and a global service's resources |
| 36 | // surface through whichever region happens to be home for it — a bucket |
| 37 | // in its own region, a hosted zone or an IAM role through us-east-1 — so a |
| 38 | // test that wires only EC2 would never exercise the global/regional |
| 39 | // ordering the catch-all's dedup depends on. |
| 40 | func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| 41 | factory := func(region string) Clients { |
| 42 | return Clients{ |
| 43 | EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{ |
| 44 | Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| 45 | InstanceId: aws.String("i-123"), |
| 46 | }}}}, |
| 47 | }}, |
| 48 | S3: &fakeS3{ |
| 49 | buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, |
| 50 | }, |
| 51 | Route53: &fakeRoute53{ |
| 52 | zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| 53 | {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, |
| 54 | }}, |
| 55 | recs: &route53.ListResourceRecordSetsOutput{}, |
| 56 | }, |
| 57 | IAM: &fakeIAM{ |
| 58 | roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ |
| 59 | {RoleName: aws.String("region-host"), Arn: aws.String("arn:aws:iam::1234:role/region-host")}, |
| 60 | }}, |
| 61 | users: &iam.ListUsersOutput{}, |
| 62 | attached: &iam.ListAttachedRolePoliciesOutput{}, |
| 63 | }, |
| 64 | Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ |
| 65 | ResourceTagMappingList: []rgtypes.ResourceTagMapping{ |
| 66 | {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")}, |
| 67 | {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")}, |
| 68 | {ResourceARN: aws.String("arn:aws:s3:::carlos-data-ie")}, |
| 69 | {ResourceARN: aws.String("arn:aws:route53:::hostedzone/Z1")}, |
| 70 | {ResourceARN: aws.String("arn:aws:iam::1234:role/region-host")}, |
| 71 | }, |
| 72 | }}, |
| 73 | } |
| 74 | } |
| 75 | p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") |
| 76 | |
| 77 | inv, err := p.Inventory(context.Background()) |
| 78 | if err != nil { |
| 79 | t.Fatalf("Inventory: %v", err) |
| 80 | } |
| 81 | machines := inv.ByKind(infraview.KindMachine) |
| 82 | if len(machines) != 1 { |
| 83 | t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines)) |
| 84 | } |
| 85 | buckets := inv.ByKind(infraview.KindBucket) |
| 86 | if len(buckets) != 1 { |
| 87 | t.Fatalf("buckets = %d, want 1 (the catch-all must not duplicate the S3 mapping)", len(buckets)) |
| 88 | } |
| 89 | zones := inv.ByKind(infraview.KindDNSZone) |
| 90 | if len(zones) != 1 { |
| 91 | t.Fatalf("zones = %d, want 1 (the catch-all must not duplicate the Route53 mapping)", len(zones)) |
| 92 | } |
| 93 | identities := inv.ByKind(infraview.KindIdentity) |
| 94 | if len(identities) != 1 { |
| 95 | t.Fatalf("identities = %d, want 1 (the catch-all must not duplicate the IAM mapping)", len(identities)) |
| 96 | } |
| 97 | others := inv.ByKind(infraview.KindOther) |
| 98 | if len(others) != 1 || others[0].NativeType != "sqs" { |
| 99 | t.Fatalf("others = %+v, want one sqs resource", others) |
| 100 | } |
| 101 | if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() { |
| 102 | t.Fatalf("header not stamped: %+v", inv) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // Auth-shaped failure with nothing discovered is the one case that must |
| 107 | // reach the caller as an error — it is what latches the console's pass off |
| 108 | // so an ungranted deployment says "not granted" instead of erroring hourly. |
| 109 | func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) { |
| 110 | authErr := &fakeAPIErr{code: "AccessDenied"} |
| 111 | factory := func(region string) Clients { |
| 112 | return Clients{ |
| 113 | EC2: &fakeEC2Failing{err: authErr}, |
| 114 | Tagging: &fakeTagging{err: authErr}, |
| 115 | } |
| 116 | } |
| 117 | p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| 118 | _, err := p.Inventory(context.Background()) |
| 119 | if err == nil { |
| 120 | t.Fatal("want an error when every read is denied and nothing was discovered") |
| 121 | } |
| 122 | if !errors.Is(err, ErrNoGrant) { |
| 123 | t.Fatalf("err = %v, want it to satisfy errors.Is(err, ErrNoGrant) — this is what the console's latch matches on", err) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // IsAuthShaped must also recognize ErrNoGrant itself, not just a denied |
| 128 | // smithy code — Inventory's assembly-time denial (this test's own sibling, |
| 129 | // TestAuthShapedWithNoResourcesIsAnError) has no surviving smithy.APIError |
| 130 | // to classify by the time it reaches the caller, only this sentinel. |
| 131 | func TestIsAuthShapedRecognizesErrNoGrant(t *testing.T) { |
| 132 | if !IsAuthShaped(ErrNoGrant) { |
| 133 | t.Fatal("IsAuthShaped(ErrNoGrant) = false, want true") |
| 134 | } |
| 135 | if IsAuthShaped(errors.New("some other error")) { |
| 136 | t.Fatal("IsAuthShaped(plain error) = true, want false") |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // The same denial, but something WAS discovered: the inventory is returned |
| 141 | // with the failures named in Partial, never thrown away. |
| 142 | func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) { |
| 143 | factory := func(region string) Clients { |
| 144 | return Clients{ |
| 145 | EC2: &fakeEC2{ |
| 146 | instances: &ec2.DescribeInstancesOutput{ |
| 147 | Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| 148 | InstanceId: aws.String("i-123"), |
| 149 | }}}}, |
| 150 | }, |
| 151 | errVols: errors.New("AccessDenied: not authorized"), |
| 152 | }, |
| 153 | Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")}, |
| 154 | } |
| 155 | } |
| 156 | p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| 157 | inv, err := p.Inventory(context.Background()) |
| 158 | if err != nil { |
| 159 | t.Fatalf("Inventory: %v", err) |
| 160 | } |
| 161 | if len(inv.Partial) == 0 { |
| 162 | t.Fatal("failures must be named in Partial") |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // A tagged resource that happens to share its trailing ARN component with |
| 167 | // an unrelated typed resource's name must NOT be swallowed by dedup: only |
| 168 | // a literal Resource.ID match may suppress a catch-all entry. Before this |
| 169 | // fix, add() also seeded the bare trailing component of every resource's |
| 170 | // ID into seen, so an IAM role named "worker" silently dropped an SQS |
| 171 | // queue also named "worker" — a common naming collision, not a rare one. |
| 172 | func TestCatchAllSurvivesTrailingNameCollision(t *testing.T) { |
| 173 | factory := func(region string) Clients { |
| 174 | return Clients{ |
| 175 | IAM: &fakeIAM{ |
| 176 | roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ |
| 177 | {RoleName: aws.String("worker"), Arn: aws.String("arn:aws:iam::1234:role/worker")}, |
| 178 | }}, |
| 179 | users: &iam.ListUsersOutput{}, |
| 180 | attached: &iam.ListAttachedRolePoliciesOutput{}, |
| 181 | }, |
| 182 | Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ |
| 183 | ResourceTagMappingList: []rgtypes.ResourceTagMapping{ |
| 184 | {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:worker")}, |
| 185 | }, |
| 186 | }}, |
| 187 | } |
| 188 | } |
| 189 | p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") |
| 190 | |
| 191 | inv, err := p.Inventory(context.Background()) |
| 192 | if err != nil { |
| 193 | t.Fatalf("Inventory: %v", err) |
| 194 | } |
| 195 | identities := inv.ByKind(infraview.KindIdentity) |
| 196 | if len(identities) != 1 { |
| 197 | t.Fatalf("identities = %d, want 1 (the IAM role)", len(identities)) |
| 198 | } |
| 199 | others := inv.ByKind(infraview.KindOther) |
| 200 | if len(others) != 1 || others[0].NativeType != "sqs" { |
| 201 | t.Fatalf("others = %+v, want the SQS queue to survive sharing the trailing name %q with the IAM role", others, "worker") |
| 202 | } |
| 203 | } |
| 204 | |