package awsinv import ( "context" "errors" "fmt" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/acm" acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" "github.com/aws/aws-sdk-go-v2/service/ec2" "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/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/aws/smithy-go" "github.com/carlosframework/platform/internal/infraview" ) type fakeAPIErr struct{ code string } func (e *fakeAPIErr) Error() string { return e.code } func (e *fakeAPIErr) ErrorCode() string { return e.code } func (e *fakeAPIErr) ErrorMessage() string { return e.code } func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient } // fakeEC2Failing fails every call with the same error. type fakeEC2Failing struct{ err error } func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { return nil, f.err } func (f *fakeEC2Failing) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) { return nil, f.err } type fakeS3 struct { buckets *s3.ListBucketsOutput vers *s3.GetBucketVersioningOutput } func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { return f.buckets, nil } func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil } func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { return f.vers, nil } func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { return nil, errors.New("no encryption configured") } func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) { return nil, errors.New("no public access block") } // A bucket whose posture reads fail still appears — with the attribute // ABSENT, never a false "off". "Off" is a claim this read did not earn. func TestS3BucketMapping(t *testing.T) { f := &fakeS3{ buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled}, } rs, partial := s3Resources(context.Background(), f) if len(partial) != 0 || len(rs) != 1 { t.Fatalf("rs=%+v partial=%v", rs, partial) } b := rs[0] if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" { t.Errorf("bucket = %+v", b) } if b.Attr("versioning") != "enabled" { t.Errorf("versioning = %q, want enabled", b.Attr("versioning")) } if _, ok := b.Attrs["encryption"]; ok { t.Errorf("a failed encryption read must leave the attribute absent, not claim a value") } } type fakeRoute53 struct { zones *route53.ListHostedZonesOutput recs *route53.ListResourceRecordSetsOutput } func (f *fakeRoute53) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { return f.zones, nil } func (f *fakeRoute53) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { return f.recs, nil } // Only record types that can participate in the DNS -> IP -> machine join // are materialised as their own Resource; everything else just bumps the // zone's record_count, because a zone can hold thousands of records. func TestRoute53RecordsMaterialiseOnlyJoinable(t *testing.T) { f := &fakeRoute53{ zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ {Id: aws.String("/hostedzone/Z1"), Name: aws.String("carlosframework.com.")}, }}, recs: &route53.ListResourceRecordSetsOutput{ResourceRecordSets: []r53types.ResourceRecordSet{ { Name: aws.String("carlosframework.com."), Type: r53types.RRTypeA, ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.1")}}, }, { Name: aws.String("carlosframework.com."), Type: r53types.RRTypeTxt, ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("\"v=spf1 -all\"")}}, }, }}, } rs, partial := route53Resources(context.Background(), f) if len(partial) != 0 { t.Fatalf("partial = %v, want none", partial) } dnsRecords := 0 var zone *infraview.Resource for i := range rs { switch rs[i].Kind { case infraview.KindDNSRecord: dnsRecords++ if rs[i].Attr("type") != "A" { t.Errorf("only the A record should have been materialised, got type %q", rs[i].Attr("type")) } case infraview.KindDNSZone: zone = &rs[i] } } if dnsRecords != 1 { t.Fatalf("dns_record resources = %d, want exactly 1 (the A record, not the TXT)", dnsRecords) } if zone == nil { t.Fatal("no dns_zone resource produced") } if zone.Attr("record_count") != "2" { t.Errorf("record_count = %q, want 2 (both records counted)", zone.Attr("record_count")) } } // fakeRoute53Stuck always claims IsTruncated with the same next cursor, no // matter what StartRecordName/Type it was called with — simulating a // pagination cursor that never advances (a single record name owning a // whole page is the real-world case). route53Resources must stop rather // than spin forever against a live API on an hourly pass. type fakeRoute53Stuck struct { zones *route53.ListHostedZonesOutput calls int } func (f *fakeRoute53Stuck) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { return f.zones, nil } func (f *fakeRoute53Stuck) ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { f.calls++ return &route53.ListResourceRecordSetsOutput{ ResourceRecordSets: []r53types.ResourceRecordSet{ { Name: aws.String("stuck.carlosframework.com."), Type: r53types.RRTypeA, ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.9")}}, }, }, IsTruncated: true, NextRecordName: aws.String("stuck.carlosframework.com."), NextRecordType: r53types.RRTypeA, }, nil } func TestRoute53PaginationStopsWhenCursorDoesNotAdvance(t *testing.T) { f := &fakeRoute53Stuck{ zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ {Id: aws.String("/hostedzone/Z2"), Name: aws.String("carlosframework.com.")}, }}, } done := make(chan struct{}) var rs []infraview.Resource var partial []string go func() { rs, partial = route53Resources(context.Background(), f) close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("route53Resources did not return — a non-advancing cursor spun forever") } if f.calls > 3 { t.Fatalf("ListResourceRecordSets called %d times for a cursor that never advances, want it to stop quickly", f.calls) } if len(partial) == 0 { t.Fatal("a stalled cursor must be named in partial, not silently truncate the zone") } zone := find(rs, infraview.KindDNSZone, "Z2") if zone == nil { t.Fatal("the zone itself must still be returned even though pagination stalled") } } // fakeRoute53AdvancingIdentifier simulates a weighted/latency/geolocation/ // failover record set spanning THREE pages, where the second-to-third page // boundary repeats the SAME name and type and only NextRecordIdentifier // moves. Two pages alone can't distinguish the fix (the second page can // coincidentally end the response too), so this needs a run where the // name+type also repeats mid-stream, which is exactly what a stall check // blind to the identifier misreads as "the cursor didn't move". type fakeRoute53AdvancingIdentifier struct { zones *route53.ListHostedZonesOutput calls int } func (f *fakeRoute53AdvancingIdentifier) ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { return f.zones, nil } func (f *fakeRoute53AdvancingIdentifier) ListResourceRecordSets(_ context.Context, in *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { f.calls++ const name = "weighted.carlosframework.com." switch f.calls { case 1: return &route53.ListResourceRecordSetsOutput{ ResourceRecordSets: []r53types.ResourceRecordSet{ { Name: aws.String(name), Type: r53types.RRTypeA, SetIdentifier: aws.String("primary"), ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.10")}}, }, }, IsTruncated: true, NextRecordName: aws.String(name), NextRecordType: r53types.RRTypeA, NextRecordIdentifier: aws.String("secondary"), }, nil case 2: if aws.ToString(in.StartRecordIdentifier) != "secondary" { return nil, fmt.Errorf("want StartRecordIdentifier=secondary, got %q", aws.ToString(in.StartRecordIdentifier)) } // SAME name and type as the cursor we were just handed — only the // identifier is different. A stall check that ignores the // identifier sees name+type match and stops here, one page short. return &route53.ListResourceRecordSetsOutput{ ResourceRecordSets: []r53types.ResourceRecordSet{ { Name: aws.String(name), Type: r53types.RRTypeA, SetIdentifier: aws.String("secondary"), ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.11")}}, }, }, IsTruncated: true, NextRecordName: aws.String(name), NextRecordType: r53types.RRTypeA, NextRecordIdentifier: aws.String("tertiary"), }, nil case 3: if aws.ToString(in.StartRecordIdentifier) != "tertiary" { return nil, fmt.Errorf("want StartRecordIdentifier=tertiary, got %q", aws.ToString(in.StartRecordIdentifier)) } return &route53.ListResourceRecordSetsOutput{ ResourceRecordSets: []r53types.ResourceRecordSet{ { Name: aws.String(name), Type: r53types.RRTypeA, SetIdentifier: aws.String("tertiary"), ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("52.0.0.12")}}, }, }, IsTruncated: false, }, nil default: return nil, fmt.Errorf("unexpected extra call %d", f.calls) } } // The identical-name-and-type case at a page boundary must keep paginating // (and then terminate normally) rather than being misclassified as a // stall — the name/type/identifier triple only counts as stalled when ALL // THREE are unchanged from the request just made. func TestRoute53PaginationAdvancesOnIdentifierAlone(t *testing.T) { f := &fakeRoute53AdvancingIdentifier{ zones: &route53.ListHostedZonesOutput{HostedZones: []r53types.HostedZone{ {Id: aws.String("/hostedzone/Z3"), Name: aws.String("carlosframework.com.")}, }}, } rs, partial := route53Resources(context.Background(), f) if len(partial) != 0 { t.Fatalf("partial = %v, want none (an identifier-only advance must not be treated as a stall)", partial) } if f.calls != 3 { t.Fatalf("ListResourceRecordSets calls = %d, want 3 (all three pages fetched)", f.calls) } dnsRecords := 0 for _, r := range rs { if r.Kind == infraview.KindDNSRecord { dnsRecords++ } } if dnsRecords != 3 { t.Fatalf("dns_record resources = %d, want 3 (one per weighted record set member across all three pages)", dnsRecords) } zone := find(rs, infraview.KindDNSZone, "Z3") if zone == nil { t.Fatal("no zone produced") } if zone.Attr("record_count") != "3" { t.Errorf("record_count = %q, want 3", zone.Attr("record_count")) } } type fakeIAM struct { roles *iam.ListRolesOutput users *iam.ListUsersOutput attached *iam.ListAttachedRolePoliciesOutput } func (f *fakeIAM) ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) { return f.roles, nil } func (f *fakeIAM) ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error) { return f.users, nil } func (f *fakeIAM) ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) { return f.attached, nil } // Identities carry a name, a kind, and a COUNT of attached policies — // nothing more. No code path here ever reads a policy document, so no // attribute value can ever contain one. func TestIAMNeverCarriesPolicyDocuments(t *testing.T) { f := &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{Users: []iamtypes.User{ {UserName: aws.String("deploy-bot"), Arn: aws.String("arn:aws:iam::1234:user/deploy-bot")}, }}, attached: &iam.ListAttachedRolePoliciesOutput{AttachedPolicies: []iamtypes.AttachedPolicy{ {PolicyName: aws.String("AdministratorAccess")}, {PolicyName: aws.String("ReadOnlyAccess")}, }}, } rs, partial := iamResources(context.Background(), f) if len(partial) != 0 || len(rs) != 2 { t.Fatalf("rs=%+v partial=%v", rs, partial) } role := find(rs, infraview.KindIdentity, "arn:aws:iam::1234:role/region-host") if role == nil { t.Fatalf("no role in %+v", rs) } if role.Attr("attached_policies") != "2" { t.Errorf("attached_policies = %q, want 2", role.Attr("attached_policies")) } for _, r := range rs { for k, v := range r.Attrs { if k == "policy" || k == "policy_document" { t.Errorf("resource %+v carries a policy document field %q", r, k) } if strings.Contains(v, "Statement") { t.Errorf("attribute %s = %q looks like a policy document", k, v) } } } } type fakeCloudWatch struct { alarms *cloudwatch.DescribeAlarmsOutput } func (f *fakeCloudWatch) DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) { return f.alarms, nil } // An alarm on the InstanceId dimension names its target machine, which is // what lets the page draw the alarm next to the instance it watches. func TestAlarmTargetsInstanceDimension(t *testing.T) { f := &fakeCloudWatch{alarms: &cloudwatch.DescribeAlarmsOutput{MetricAlarms: []cwtypes.MetricAlarm{ { AlarmArn: aws.String("arn:aws:cloudwatch:eu-west-1:1234:alarm:cpu-high"), AlarmName: aws.String("cpu-high"), StateValue: cwtypes.StateValueAlarm, MetricName: aws.String("CPUUtilization"), Dimensions: []cwtypes.Dimension{{Name: aws.String("InstanceId"), Value: aws.String("i-123")}}, }, }}} rs, partial := alarmResources(context.Background(), f, "eu-west-1") if len(partial) != 0 || len(rs) != 1 { t.Fatalf("rs=%+v partial=%v", rs, partial) } a := rs[0] if a.Kind != infraview.KindAlarm || a.Name != "cpu-high" || a.Region != "eu-west-1" { t.Errorf("alarm = %+v", a) } if a.Attr("state") != "ALARM" { t.Errorf("state = %q, want ALARM", a.Attr("state")) } if a.Attr("target") != "i-123" { t.Errorf("target = %q, want i-123", a.Attr("target")) } } type fakeACM struct { certs *acm.ListCertificatesOutput } func (f *fakeACM) ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) { return f.certs, nil } func TestCertMapping(t *testing.T) { notAfter, err := time.Parse("2006-01-02", "2027-01-15") if err != nil { t.Fatalf("parse notAfter: %v", err) } f := &fakeACM{certs: &acm.ListCertificatesOutput{CertificateSummaryList: []acmtypes.CertificateSummary{ { CertificateArn: aws.String("arn:aws:acm:eu-west-1:1234:certificate/abc"), DomainName: aws.String("carloku.com"), Status: acmtypes.CertificateStatusIssued, NotAfter: ¬After, }, }}} rs, partial := certResources(context.Background(), f, "eu-west-1") if len(partial) != 0 || len(rs) != 1 { t.Fatalf("rs=%+v partial=%v", rs, partial) } c := rs[0] if c.Kind != infraview.KindCertificate || c.Name != "carloku.com" || c.Region != "eu-west-1" { t.Errorf("cert = %+v", c) } if c.Attr("status") != "ISSUED" { t.Errorf("status = %q, want ISSUED", c.Attr("status")) } if c.Attr("expires") != "2027-01-15" { t.Errorf("expires = %q, want 2027-01-15", c.Attr("expires")) } } func TestParseARN(t *testing.T) { cases := []struct { arn, service, id string }{ {"arn:aws:ec2:eu-west-1:1234:instance/i-123", "ec2", "i-123"}, {"arn:aws:s3:::carlos-data-ie", "s3", "carlos-data-ie"}, {"arn:aws:sqs:eu-west-1:1234:some-queue", "sqs", "some-queue"}, {"arn:aws:iam::1234:role/region-host", "iam", "region-host"}, {"not-an-arn", "", ""}, {"", "", ""}, } for _, c := range cases { service, id := parseARN(c.arn) if service != c.service || id != c.id { t.Errorf("parseARN(%q) = (%q, %q), want (%q, %q)", c.arn, service, id, c.service, c.id) } } } // Route53 returns record and zone names octal-escaped, so the wildcard every // CARLOS deployment leans on arrives as `\052.woodstar.app`. It reached the // console's architecture page in that form and was read there by a person // before anyone noticed — decode it at the boundary that produces it. func TestDNSNamesAreUnescapedAtTheBoundary(t *testing.T) { for _, c := range []struct{ in, want string }{ {`\052.woodstar.app`, "*.woodstar.app"}, {"plain.example.com", "plain.example.com"}, {`a\055b.example.com`, "a-b.example.com"}, // Not printable ASCII: left exactly as the API sent it, because a // half-decoded name is worse than an escaped one. {`\200.example.com`, `\200.example.com`}, // A trailing partial escape must not run off the end. {`weird\05`, `weird\05`}, } { if got := unescapeDNSName(c.in); got != c.want { t.Errorf("unescapeDNSName(%q) = %q, want %q", c.in, got, c.want) } } }