| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/aws/aws-sdk-go-v2/aws" |
| 12 | "github.com/aws/aws-sdk-go-v2/service/acm" |
| 13 | acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" |
| 14 | "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| 15 | cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" |
| 16 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 17 | "github.com/aws/aws-sdk-go-v2/service/iam" |
| 18 | iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| 19 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 20 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 21 | "github.com/aws/aws-sdk-go-v2/service/s3" |
| 22 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| 23 | "github.com/aws/smithy-go" |
| 24 | "github.com/carlosframework/platform/internal/infraview" |
| 25 | ) |
| 26 | |
| 27 | type fakeAPIErr struct{ code string } |
| 28 | |
| 29 | func (e *fakeAPIErr) Error() string { return e.code } |
| 30 | func (e *fakeAPIErr) ErrorCode() string { return e.code } |
| 31 | func (e *fakeAPIErr) ErrorMessage() string { return e.code } |
| 32 | func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient } |
| 33 | |
| 34 | // fakeEC2Failing fails every call with the same error. |
| 35 | type fakeEC2Failing struct{ err error } |
| 36 | |
| 37 | func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| 38 | return nil, f.err |
| 39 | } |
| 40 | func (f *fakeEC2Failing) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) { |
| 41 | return nil, f.err |
| 42 | } |
| 43 | func (f *fakeEC2Failing) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) { |
| 44 | return nil, f.err |
| 45 | } |
| 46 | func (f *fakeEC2Failing) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) { |
| 47 | return nil, f.err |
| 48 | } |
| 49 | func (f *fakeEC2Failing) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { |
| 50 | return nil, f.err |
| 51 | } |
| 52 | func (f *fakeEC2Failing) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { |
| 53 | return nil, f.err |
| 54 | } |
| 55 | func (f *fakeEC2Failing) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) { |
| 56 | return nil, f.err |
| 57 | } |
| 58 | |
| 59 | type fakeS3 struct { |
| 60 | buckets *s3.ListBucketsOutput |
| 61 | vers *s3.GetBucketVersioningOutput |
| 62 | } |
| 63 | |
| 64 | func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { |
| 65 | return f.buckets, nil |
| 66 | } |
| 67 | func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { |
| 68 | return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil |
| 69 | } |
| 70 | func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { |
| 71 | return f.vers, nil |
| 72 | } |
| 73 | func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { |
| 74 | return nil, errors.New("no encryption configured") |
| 75 | } |
| 76 | func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) { |
| 77 | return nil, errors.New("no public access block") |
| 78 | } |
| 79 | |
| 80 | // A bucket whose posture reads fail still appears — with the attribute |
| 81 | // ABSENT, never a false "off". "Off" is a claim this read did not earn. |
| 82 | func TestS3BucketMapping(t *testing.T) { |
| 83 | f := &fakeS3{ |
| 84 | buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, |
| 85 | vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled}, |
| 86 | } |
| 87 | rs, partial := s3Resources(context.Background(), f) |
| 88 | if len(partial) != 0 || len(rs) != 1 { |
| 89 | t.Fatalf("rs=%+v partial=%v", rs, partial) |
| 90 | } |
| 91 | b := rs[0] |
| 92 | if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" { |
| 93 | t.Errorf("bucket = %+v", b) |
| 94 | } |
| 95 | if b.Attr("versioning") != "enabled" { |
| 96 | t.Errorf("versioning = %q, want enabled", b.Attr("versioning")) |
| 97 | } |
| 98 | if _, ok := b.Attrs["encryption"]; ok { |
| 99 | t.Errorf("a failed encryption read must leave the attribute absent, not claim a value") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | type fakeRoute53 struct { |
| 104 | zones *route53.ListHostedZonesOutput |
| 105 | recs *route53.ListResourceRecordSetsOutput |
| 106 | } |
| 107 | |
| 108 | func (f *fakeRoute53) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { |
| 109 | return f.zones, nil |
| 110 | } |
| 111 | func (f *fakeRoute53) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { |
| 112 | return f.recs, nil |
| 113 | } |
| 114 | |
| 115 | // Only record types that can participate in the DNS -> IP -> machine join |
| 116 | // are materialised as their own Resource; everything else just bumps the |
| 117 | // zone's record_count, because a zone can hold thousands of records. |
| 118 | func TestRoute53RecordsMaterialiseOnlyJoinable(t *testing.T) { |
| 119 | f := &fakeRoute53{ |
| 120 | zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| 121 | {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, |
| 122 | }}, |
| 123 | recs: &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []r53types.ResourceRecordSet{ |
| 124 | { |
| 125 | Name: aws.String("carlosframework.com."), Type: r53types.RRTypeA, |
| 126 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.1")}}, |
| 127 | }, |
| 128 | { |
| 129 | Name: aws.String("carlosframework.com."), Type: r53types.RRTypeTxt, |
| 130 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("\"v=spf1 -all\"")}}, |
| 131 | }, |
| 132 | }}, |
| 133 | } |
| 134 | rs, partial := route53Resources(context.Background(), f) |
| 135 | if len(partial) != 0 { |
| 136 | t.Fatalf("partial = %v, want none", partial) |
| 137 | } |
| 138 | dnsRecords := 0 |
| 139 | var zone *infraview.Resource |
| 140 | for i := range rs { |
| 141 | switch rs[i].Kind { |
| 142 | case infraview.KindDNSRecord: |
| 143 | dnsRecords++ |
| 144 | if rs[i].Attr("type") != "A" { |
| 145 | t.Errorf("only the A record should have been materialised, got type %q", rs[i].Attr("type")) |
| 146 | } |
| 147 | case infraview.KindDNSZone: |
| 148 | zone = &rs[i] |
| 149 | } |
| 150 | } |
| 151 | if dnsRecords != 1 { |
| 152 | t.Fatalf("dns_record resources = %d, want exactly 1 (the A record, not the TXT)", dnsRecords) |
| 153 | } |
| 154 | if zone == nil { |
| 155 | t.Fatal("no dns_zone resource produced") |
| 156 | } |
| 157 | if zone.Attr("record_count") != "2" { |
| 158 | t.Errorf("record_count = %q, want 2 (both records counted)", zone.Attr("record_count")) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // fakeRoute53Stuck always claims IsTruncated with the same next cursor, no |
| 163 | // matter what StartRecordName/Type it was called with — simulating a |
| 164 | // pagination cursor that never advances (a single record name owning a |
| 165 | // whole page is the real-world case). route53Resources must stop rather |
| 166 | // than spin forever against a live API on an hourly pass. |
| 167 | type fakeRoute53Stuck struct { |
| 168 | zones *route53.ListHostedZonesOutput |
| 169 | calls int |
| 170 | } |
| 171 | |
| 172 | func (f *fakeRoute53Stuck) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { |
| 173 | return f.zones, nil |
| 174 | } |
| 175 | |
| 176 | func (f *fakeRoute53Stuck) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { |
| 177 | f.calls++ |
| 178 | return &route53.ListResourceRecordSetsOutput{ |
| 179 | ResourceRecordSets: []r53types.ResourceRecordSet{ |
| 180 | { |
| 181 | Name: aws.String("stuck.carlosframework.com."), Type: r53types.RRTypeA, |
| 182 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.9")}}, |
| 183 | }, |
| 184 | }, |
| 185 | IsTruncated: true, |
| 186 | NextRecordName: aws.String("stuck.carlosframework.com."), |
| 187 | NextRecordType: r53types.RRTypeA, |
| 188 | }, nil |
| 189 | } |
| 190 | |
| 191 | func TestRoute53PaginationStopsWhenCursorDoesNotAdvance(t *testing.T) { |
| 192 | f := &fakeRoute53Stuck{ |
| 193 | zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| 194 | {Id: aws.String("/hostedzone/Z2"), Name: aws.String("carlosframework.com.")}, |
| 195 | }}, |
| 196 | } |
| 197 | |
| 198 | done := make(chan struct{}) |
| 199 | var rs []infraview.Resource |
| 200 | var partial []string |
| 201 | go func() { |
| 202 | rs, partial = route53Resources(context.Background(), f) |
| 203 | close(done) |
| 204 | }() |
| 205 | select { |
| 206 | case <-done: |
| 207 | case <-time.After(2 * time.Second): |
| 208 | t.Fatal("route53Resources did not return — a non-advancing cursor spun forever") |
| 209 | } |
| 210 | |
| 211 | if f.calls > 3 { |
| 212 | t.Fatalf("ListResourceRecordSets called %d times for a cursor that never advances, want it to stop quickly", f.calls) |
| 213 | } |
| 214 | if len(partial) == 0 { |
| 215 | t.Fatal("a stalled cursor must be named in partial, not silently truncate the zone") |
| 216 | } |
| 217 | zone := find(rs, infraview.KindDNSZone, "Z2") |
| 218 | if zone == nil { |
| 219 | t.Fatal("the zone itself must still be returned even though pagination stalled") |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // fakeRoute53AdvancingIdentifier simulates a weighted/latency/geolocation/ |
| 224 | // failover record set spanning THREE pages, where the second-to-third page |
| 225 | // boundary repeats the SAME name and type and only NextRecordIdentifier |
| 226 | // moves. Two pages alone can't distinguish the fix (the second page can |
| 227 | // coincidentally end the response too), so this needs a run where the |
| 228 | // name+type also repeats mid-stream, which is exactly what a stall check |
| 229 | // blind to the identifier misreads as "the cursor didn't move". |
| 230 | type fakeRoute53AdvancingIdentifier struct { |
| 231 | zones *route53.ListHostedZonesOutput |
| 232 | calls int |
| 233 | } |
| 234 | |
| 235 | func (f *fakeRoute53AdvancingIdentifier) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { |
| 236 | return f.zones, nil |
| 237 | } |
| 238 | |
| 239 | func (f *fakeRoute53AdvancingIdentifier) ListResourceRecordSets(_ context.Context, in *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { |
| 240 | f.calls++ |
| 241 | const name = "weighted.carlosframework.com." |
| 242 | switch f.calls { |
| 243 | case 1: |
| 244 | return &route53.ListResourceRecordSetsOutput{ |
| 245 | ResourceRecordSets: []r53types.ResourceRecordSet{ |
| 246 | { |
| 247 | Name: aws.String(name), Type: r53types.RRTypeA, |
| 248 | SetIdentifier: aws.String("primary"), |
| 249 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.10")}}, |
| 250 | }, |
| 251 | }, |
| 252 | IsTruncated: true, |
| 253 | NextRecordName: aws.String(name), |
| 254 | NextRecordType: r53types.RRTypeA, |
| 255 | NextRecordIdentifier: aws.String("secondary"), |
| 256 | }, nil |
| 257 | case 2: |
| 258 | if aws.ToString(in.StartRecordIdentifier) != "secondary" { |
| 259 | return nil, fmt.Errorf("want StartRecordIdentifier=secondary, got %q", aws.ToString(in.StartRecordIdentifier)) |
| 260 | } |
| 261 | // SAME name and type as the cursor we were just handed — only the |
| 262 | // identifier is different. A stall check that ignores the |
| 263 | // identifier sees name+type match and stops here, one page short. |
| 264 | return &route53.ListResourceRecordSetsOutput{ |
| 265 | ResourceRecordSets: []r53types.ResourceRecordSet{ |
| 266 | { |
| 267 | Name: aws.String(name), Type: r53types.RRTypeA, |
| 268 | SetIdentifier: aws.String("secondary"), |
| 269 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.11")}}, |
| 270 | }, |
| 271 | }, |
| 272 | IsTruncated: true, |
| 273 | NextRecordName: aws.String(name), |
| 274 | NextRecordType: r53types.RRTypeA, |
| 275 | NextRecordIdentifier: aws.String("tertiary"), |
| 276 | }, nil |
| 277 | case 3: |
| 278 | if aws.ToString(in.StartRecordIdentifier) != "tertiary" { |
| 279 | return nil, fmt.Errorf("want StartRecordIdentifier=tertiary, got %q", aws.ToString(in.StartRecordIdentifier)) |
| 280 | } |
| 281 | return &route53.ListResourceRecordSetsOutput{ |
| 282 | ResourceRecordSets: []r53types.ResourceRecordSet{ |
| 283 | { |
| 284 | Name: aws.String(name), Type: r53types.RRTypeA, |
| 285 | SetIdentifier: aws.String("tertiary"), |
| 286 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.12")}}, |
| 287 | }, |
| 288 | }, |
| 289 | IsTruncated: false, |
| 290 | }, nil |
| 291 | default: |
| 292 | return nil, fmt.Errorf("unexpected extra call %d", f.calls) |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | // The identical-name-and-type case at a page boundary must keep paginating |
| 297 | // (and then terminate normally) rather than being misclassified as a |
| 298 | // stall — the name/type/identifier triple only counts as stalled when ALL |
| 299 | // THREE are unchanged from the request just made. |
| 300 | func TestRoute53PaginationAdvancesOnIdentifierAlone(t *testing.T) { |
| 301 | f := &fakeRoute53AdvancingIdentifier{ |
| 302 | zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ |
| 303 | {Id: aws.String("/hostedzone/Z3"), Name: aws.String("carlosframework.com.")}, |
| 304 | }}, |
| 305 | } |
| 306 | rs, partial := route53Resources(context.Background(), f) |
| 307 | if len(partial) != 0 { |
| 308 | t.Fatalf("partial = %v, want none (an identifier-only advance must not be treated as a stall)", partial) |
| 309 | } |
| 310 | if f.calls != 3 { |
| 311 | t.Fatalf("ListResourceRecordSets calls = %d, want 3 (all three pages fetched)", f.calls) |
| 312 | } |
| 313 | dnsRecords := 0 |
| 314 | for _, r := range rs { |
| 315 | if r.Kind == infraview.KindDNSRecord { |
| 316 | dnsRecords++ |
| 317 | } |
| 318 | } |
| 319 | if dnsRecords != 3 { |
| 320 | t.Fatalf("dns_record resources = %d, want 3 (one per weighted record set member across all three pages)", dnsRecords) |
| 321 | } |
| 322 | zone := find(rs, infraview.KindDNSZone, "Z3") |
| 323 | if zone == nil { |
| 324 | t.Fatal("no zone produced") |
| 325 | } |
| 326 | if zone.Attr("record_count") != "3" { |
| 327 | t.Errorf("record_count = %q, want 3", zone.Attr("record_count")) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | type fakeIAM struct { |
| 332 | roles *iam.ListRolesOutput |
| 333 | users *iam.ListUsersOutput |
| 334 | attached *iam.ListAttachedRolePoliciesOutput |
| 335 | } |
| 336 | |
| 337 | func (f *fakeIAM) ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) { |
| 338 | return f.roles, nil |
| 339 | } |
| 340 | func (f *fakeIAM) ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error) { |
| 341 | return f.users, nil |
| 342 | } |
| 343 | func (f *fakeIAM) ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { |
| 344 | return f.attached, nil |
| 345 | } |
| 346 | |
| 347 | // Identities carry a name, a kind, and a COUNT of attached policies — |
| 348 | // nothing more. No code path here ever reads a policy document, so no |
| 349 | // attribute value can ever contain one. |
| 350 | func TestIAMNeverCarriesPolicyDocuments(t *testing.T) { |
| 351 | f := &fakeIAM{ |
| 352 | roles: &iam.ListRolesOutput{Roles: []iamtypes.Role{ |
| 353 | {RoleName: aws.String("region-host"), Arn: aws.String("arn:aws:iam::1234:role/region-host")}, |
| 354 | }}, |
| 355 | users: &iam.ListUsersOutput{Users: []iamtypes.User{ |
| 356 | {UserName: aws.String("deploy-bot"), Arn: aws.String("arn:aws:iam::1234:user/deploy-bot")}, |
| 357 | }}, |
| 358 | attached: &iam.ListAttachedRolePoliciesOutput{AttachedPolicies: []iamtypes.AttachedPolicy{ |
| 359 | {PolicyName: aws.String("AdministratorAccess")}, |
| 360 | {PolicyName: aws.String("ReadOnlyAccess")}, |
| 361 | }}, |
| 362 | } |
| 363 | rs, partial := iamResources(context.Background(), f) |
| 364 | if len(partial) != 0 || len(rs) != 2 { |
| 365 | t.Fatalf("rs=%+v partial=%v", rs, partial) |
| 366 | } |
| 367 | role := find(rs, infraview.KindIdentity, "arn:aws:iam::1234:role/region-host") |
| 368 | if role == nil { |
| 369 | t.Fatalf("no role in %+v", rs) |
| 370 | } |
| 371 | if role.Attr("attached_policies") != "2" { |
| 372 | t.Errorf("attached_policies = %q, want 2", role.Attr("attached_policies")) |
| 373 | } |
| 374 | for _, r := range rs { |
| 375 | for k, v := range r.Attrs { |
| 376 | if k == "policy" || k == "policy_document" { |
| 377 | t.Errorf("resource %+v carries a policy document field %q", r, k) |
| 378 | } |
| 379 | if strings.Contains(v, "Statement") { |
| 380 | t.Errorf("attribute %s = %q looks like a policy document", k, v) |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | type fakeCloudWatch struct { |
| 387 | alarms *cloudwatch.DescribeAlarmsOutput |
| 388 | } |
| 389 | |
| 390 | func (f *fakeCloudWatch) DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) { |
| 391 | return f.alarms, nil |
| 392 | } |
| 393 | |
| 394 | // An alarm on the InstanceId dimension names its target machine, which is |
| 395 | // what lets the page draw the alarm next to the instance it watches. |
| 396 | func TestAlarmTargetsInstanceDimension(t *testing.T) { |
| 397 | f := &fakeCloudWatch{alarms: &cloudwatch.DescribeAlarmsOutput{MetricAlarms: []cwtypes.MetricAlarm{ |
| 398 | { |
| 399 | AlarmArn: aws.String("arn:aws:cloudwatch:eu-west-1:1234:alarm:cpu-high"), AlarmName: aws.String("cpu-high"), |
| 400 | StateValue: cwtypes.StateValueAlarm, MetricName: aws.String("CPUUtilization"), |
| 401 | Dimensions: []cwtypes.Dimension{{Name: aws.String("InstanceId"), Value: aws.String("i-123")}}, |
| 402 | }, |
| 403 | }}} |
| 404 | rs, partial := alarmResources(context.Background(), f, "eu-west-1") |
| 405 | if len(partial) != 0 || len(rs) != 1 { |
| 406 | t.Fatalf("rs=%+v partial=%v", rs, partial) |
| 407 | } |
| 408 | a := rs[0] |
| 409 | if a.Kind != infraview.KindAlarm || a.Name != "cpu-high" || a.Region != "eu-west-1" { |
| 410 | t.Errorf("alarm = %+v", a) |
| 411 | } |
| 412 | if a.Attr("state") != "ALARM" { |
| 413 | t.Errorf("state = %q, want ALARM", a.Attr("state")) |
| 414 | } |
| 415 | if a.Attr("target") != "i-123" { |
| 416 | t.Errorf("target = %q, want i-123", a.Attr("target")) |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | type fakeACM struct { |
| 421 | certs *acm.ListCertificatesOutput |
| 422 | } |
| 423 | |
| 424 | func (f *fakeACM) ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { |
| 425 | return f.certs, nil |
| 426 | } |
| 427 | |
| 428 | func TestCertMapping(t *testing.T) { |
| 429 | notAfter, err := time.Parse("2006-01-02", "2027-01-15") |
| 430 | if err != nil { |
| 431 | t.Fatalf("parse notAfter: %v", err) |
| 432 | } |
| 433 | f := &fakeACM{certs: &acm.ListCertificatesOutput{CertificateSummaryList: []acmtypes.CertificateSummary{ |
| 434 | { |
| 435 | CertificateArn: aws.String("arn:aws:acm:eu-west-1:1234:certificate/abc"), |
| 436 | DomainName: aws.String("carloku.com"), |
| 437 | Status: acmtypes.CertificateStatusIssued, |
| 438 | NotAfter: ¬After, |
| 439 | }, |
| 440 | }}} |
| 441 | rs, partial := certResources(context.Background(), f, "eu-west-1") |
| 442 | if len(partial) != 0 || len(rs) != 1 { |
| 443 | t.Fatalf("rs=%+v partial=%v", rs, partial) |
| 444 | } |
| 445 | c := rs[0] |
| 446 | if c.Kind != infraview.KindCertificate || c.Name != "carloku.com" || c.Region != "eu-west-1" { |
| 447 | t.Errorf("cert = %+v", c) |
| 448 | } |
| 449 | if c.Attr("status") != "ISSUED" { |
| 450 | t.Errorf("status = %q, want ISSUED", c.Attr("status")) |
| 451 | } |
| 452 | if c.Attr("expires") != "2027-01-15" { |
| 453 | t.Errorf("expires = %q, want 2027-01-15", c.Attr("expires")) |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | func TestParseARN(t *testing.T) { |
| 458 | cases := []struct { |
| 459 | arn, service, id string |
| 460 | }{ |
| 461 | {"arn:aws:ec2:eu-west-1:1234:instance/i-123", "ec2", "i-123"}, |
| 462 | {"arn:aws:s3:::carlos-data-ie", "s3", "carlos-data-ie"}, |
| 463 | {"arn:aws:sqs:eu-west-1:1234:some-queue", "sqs", "some-queue"}, |
| 464 | {"arn:aws:iam::1234:role/region-host", "iam", "region-host"}, |
| 465 | {"not-an-arn", "", ""}, |
| 466 | {"", "", ""}, |
| 467 | } |
| 468 | for _, c := range cases { |
| 469 | service, id := parseARN(c.arn) |
| 470 | if service != c.service || id != c.id { |
| 471 | t.Errorf("parseARN(%q) = (%q, %q), want (%q, %q)", c.arn, service, id, c.service, c.id) |
| 472 | } |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | // Route53 returns record and zone names octal-escaped, so the wildcard every |
| 477 | // CARLOS deployment leans on arrives as `\052.woodstar.app`. It reached the |
| 478 | // console's architecture page in that form and was read there by a person |
| 479 | // before anyone noticed — decode it at the boundary that produces it. |
| 480 | func TestDNSNamesAreUnescapedAtTheBoundary(t *testing.T) { |
| 481 | for _, c := range []struct{ in, want string }{ |
| 482 | {`\052.woodstar.app`, "*.woodstar.app"}, |
| 483 | {"plain.example.com", "plain.example.com"}, |
| 484 | {`a\055b.example.com`, "a-b.example.com"}, |
| 485 | // Not printable ASCII: left exactly as the API sent it, because a |
| 486 | // half-decoded name is worse than an escaped one. |
| 487 | {`\200.example.com`, `\200.example.com`}, |
| 488 | // A trailing partial escape must not run off the end. |
| 489 | {`weird\05`, `weird\05`}, |
| 490 | } { |
| 491 | if got := unescapeDNSName(c.in); got != c.want { |
| 492 | t.Errorf("unescapeDNSName(%q) = %q, want %q", c.in, got, c.want) |
| 493 | } |
| 494 | } |
| 495 | } |
| 496 | |