From 11f2146b715803f18a331c5f4679b4cca36d863f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 12 Jul 2026 20:53:58 +1000 Subject: [PATCH] Slice 1: round-trip, fixture-load & sanitizer tests (#10) - round-trip: schema stability + nil-port (all-ports) semantics survive JSON - golden fixture: counts, alb->app->db SG chain resolves, gateway-endpoint prefix-list route present, amazon-elb/amazon-rds ENI owners preserved, account sanitized - sanitizer: account redacted across ARNs/peers/owners, service owners kept, idempotent Closes #10 --- internal/topology/golden_test.go | 103 +++++++++++++++++++++++++++++ internal/topology/sanitize_test.go | 48 ++++++++++++++ internal/topology/snapshot_test.go | 51 ++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 internal/topology/golden_test.go create mode 100644 internal/topology/sanitize_test.go create mode 100644 internal/topology/snapshot_test.go diff --git a/internal/topology/golden_test.go b/internal/topology/golden_test.go new file mode 100644 index 0000000..4cb580a --- /dev/null +++ b/internal/topology/golden_test.go @@ -0,0 +1,103 @@ +package topology + +import ( + "encoding/json" + "os" + "testing" +) + +func loadGolden(t *testing.T) *Snapshot { + t.Helper() + b, err := os.ReadFile("../../testdata/testenv.golden.json") + if err != nil { + t.Fatal(err) + } + var s Snapshot + if err := json.Unmarshal(b, &s); err != nil { + t.Fatalf("golden fixture does not parse: %v", err) + } + return &s +} + +func TestGoldenCounts(t *testing.T) { + s := loadGolden(t) + for _, c := range []struct { + name string + got int + want int + }{ + {"vpcs", len(s.VPCs), 1}, + {"subnets", len(s.Subnets), 2}, + {"routeTables", len(s.RouteTables), 2}, + {"enis", len(s.ENIs), 5}, + {"securityGroups", len(s.SecurityGroups), 4}, + {"vpcEndpoints", len(s.VPCEndpoints), 1}, + } { + if c.got != c.want { + t.Errorf("%s: got %d, want %d", c.name, c.got, c.want) + } + } + if s.SchemaVersion != SchemaVersion { + t.Errorf("schemaVersion = %d, want %d", s.SchemaVersion, SchemaVersion) + } + if s.Account != RedactedAccount { + t.Errorf("fixture account not sanitized: %q", s.Account) + } +} + +// TestGoldenSGChain asserts the alb -> app -> db SG-references-SG chain resolves. +func TestGoldenSGChain(t *testing.T) { + s := loadGolden(t) + byName := map[string]SecurityGroup{} + for _, sg := range s.SecurityGroups { + byName[sg.Name] = sg + } + alb, app, db := byName["reachr-testenv-alb"], byName["reachr-testenv-app"], byName["reachr-testenv-db"] + if alb.ID == "" || app.ID == "" || db.ID == "" { + t.Fatalf("missing a tier SG: alb=%q app=%q db=%q", alb.ID, app.ID, db.ID) + } + if !hasIngressFromSG(app, alb.ID) { + t.Error("app SG should allow ingress from alb SG") + } + if !hasIngressFromSG(db, app.ID) { + t.Error("db SG should allow ingress from app SG") + } +} + +func hasIngressFromSG(sg SecurityGroup, peerSGID string) bool { + for _, r := range sg.Rules { + if r.Direction == Ingress && r.Peer.Kind == PeerSG && r.Peer.Value == peerSGID { + return true + } + } + return false +} + +// TestGoldenGatewayEndpointRoute asserts the S3 gateway-endpoint prefix-list route. +func TestGoldenGatewayEndpointRoute(t *testing.T) { + s := loadGolden(t) + for _, rt := range s.RouteTables { + for _, r := range rt.Routes { + if r.Target.Kind == TargetVPCEndpointGW && r.Destination.Kind == DestPrefixList { + return + } + } + } + t.Error("expected a prefix-list -> vpc-endpoint-gw route in the fixture") +} + +// TestGoldenENIOwners asserts the service-managed ENI owners survived. +func TestGoldenENIOwners(t *testing.T) { + s := loadGolden(t) + owners := map[string]bool{} + for _, e := range s.ENIs { + if e.Attachment != nil { + owners[e.Attachment.InstanceOwnerID] = true + } + } + for _, want := range []string{"amazon-elb", "amazon-rds"} { + if !owners[want] { + t.Errorf("expected an ENI owned by %q", want) + } + } +} diff --git a/internal/topology/sanitize_test.go b/internal/topology/sanitize_test.go new file mode 100644 index 0000000..4e11e12 --- /dev/null +++ b/internal/topology/sanitize_test.go @@ -0,0 +1,48 @@ +package topology + +import "testing" + +func TestSanitizeRedactsAccount(t *testing.T) { + const acct = "123456789012" + s := &Snapshot{ + Account: acct, + Subnets: []Subnet{{ARN: "arn:aws:ec2:ap-southeast-2:" + acct + ":subnet/subnet-1"}}, + SecurityGroups: []SecurityGroup{{ + ARN: "arn:aws:ec2:ap-southeast-2:" + acct + ":security-group/sg-1", + Rules: []SGRule{ + {Peer: SGPeer{Kind: PeerSG, Value: "sg-2", Account: acct}}, + {Peer: SGPeer{Kind: PeerCIDR, Value: "10.0.0.0/8"}}, + }, + }}, + ENIs: []ENI{ + {Attachment: &ENIAttachment{InstanceOwnerID: acct}}, // customer instance + {Attachment: &ENIAttachment{InstanceOwnerID: "amazon-rds"}}, // service-managed + }, + } + + Sanitize(s) + + if s.Account != RedactedAccount { + t.Errorf("Account = %q, want %q", s.Account, RedactedAccount) + } + if got := s.Subnets[0].ARN; got != "arn:aws:ec2:ap-southeast-2:"+RedactedAccount+":subnet/subnet-1" { + t.Errorf("subnet ARN not redacted: %q", got) + } + if got := s.SecurityGroups[0].Rules[0].Peer.Account; got != RedactedAccount { + t.Errorf("SG-peer account = %q, want redacted", got) + } + if got := s.ENIs[0].Attachment.InstanceOwnerID; got != RedactedAccount { + t.Errorf("instance owner = %q, want redacted", got) + } + if got := s.ENIs[1].Attachment.InstanceOwnerID; got != "amazon-rds" { + t.Errorf("service owner should be preserved, got %q", got) + } +} + +func TestSanitizeIsIdempotent(t *testing.T) { + s := &Snapshot{Account: RedactedAccount} + Sanitize(s) // must not panic or alter an already-redacted snapshot + if s.Account != RedactedAccount { + t.Errorf("Account = %q", s.Account) + } +} diff --git a/internal/topology/snapshot_test.go b/internal/topology/snapshot_test.go new file mode 100644 index 0000000..a47909d --- /dev/null +++ b/internal/topology/snapshot_test.go @@ -0,0 +1,51 @@ +package topology + +import ( + "encoding/json" + "reflect" + "testing" + "time" +) + +func p(v int32) *int32 { return &v } + +// TestRoundTrip guards schema stability and the pointer-port nil semantics +// (nil FromPort/ToPort = "all ports", which must survive marshal/unmarshal). +func TestRoundTrip(t *testing.T) { + want := &Snapshot{ + SchemaVersion: SchemaVersion, + ScannedAt: time.Date(2026, 7, 12, 9, 58, 23, 0, time.UTC), + Region: "ap-southeast-2", + Account: RedactedAccount, + RouteTables: []RouteTable{{ + ID: "rtb-1", VPCID: "vpc-1", Main: true, + Routes: []Route{{ + Destination: RouteDestination{Kind: DestPrefixList, Value: "pl-1"}, + Target: RouteTarget{Kind: TargetVPCEndpointGW, ID: "vpce-1"}, + State: "active", + }}, + }}, + SecurityGroups: []SecurityGroup{{ + ID: "sg-1", VPCID: "vpc-1", Name: "app", + Rules: []SGRule{ + {Direction: Ingress, Protocol: "tcp", FromPort: p(8080), ToPort: p(8080), Peer: SGPeer{Kind: PeerSG, Value: "sg-2"}}, + {Direction: Ingress, Protocol: "all", Peer: SGPeer{Kind: PeerSG, Value: "sg-1"}}, // nil ports = all + }, + }}, + } + + b, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got Snapshot + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(want, &got) { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", want, &got) + } + if got.SecurityGroups[0].Rules[1].FromPort != nil { + t.Error("nil FromPort (all-ports) did not survive round-trip") + } +}