| 1 | package termfence |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 11 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 12 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 13 | "github.com/aws/smithy-go" |
| 14 | smithymiddleware "github.com/aws/smithy-go/middleware" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/boxid" |
| 17 | ) |
| 18 | |
| 19 | var ( |
| 20 | box = Box{Ref: "annie-welding", Enrollment: "enr-annie-1", |
| 21 | Cloud: boxid.Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}} |
| 22 | worker = Box{Ref: "bert-sawing", Enrollment: "enr-bert-1", |
| 23 | Cloud: boxid.Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0bbb"}} |
| 24 | now = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) |
| 25 | ) |
| 26 | |
| 27 | func describe(owner, instance string, state types.InstanceStateName) *ec2.DescribeInstancesOutput { |
| 28 | return &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| 29 | OwnerId: aws.String(owner), |
| 30 | Instances: []types.Instance{{InstanceId: aws.String(instance), State: &types.InstanceState{Name: state}}}, |
| 31 | }}} |
| 32 | } |
| 33 | |
| 34 | func withRequestID(out *ec2.DescribeInstancesOutput, id string) *ec2.DescribeInstancesOutput { |
| 35 | var md smithymiddleware.Metadata |
| 36 | awsmiddleware.SetRequestIDMetadata(&md, id) |
| 37 | out.ResultMetadata = md |
| 38 | return out |
| 39 | } |
| 40 | |
| 41 | func TestATerminatedInstanceOfThisBoxIsAFence(t *testing.T) { |
| 42 | out := withRequestID(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "req-1") |
| 43 | rec, err := Verify(box, worker, FromDescribe(out, "i-0abc", "eu-west-1"), now) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | want := Record{V: 1, Box: "annie-welding", Enrollment: "enr-annie-1", Cloud: box.Cloud, State: "terminated", |
| 48 | ObservedAt: "2026-10-06T12:00:00Z", Via: "describe", RequestID: "req-1", |
| 49 | WrittenBy: WrittenBy{Box: "bert-sawing", Enrollment: "enr-bert-1"}} |
| 50 | if rec != want { |
| 51 | t.Fatalf("record = %+v\nwant %+v", rec, want) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // Every lookalike is refused. |
| 56 | func TestLookalikesAreNotEvidence(t *testing.T) { |
| 57 | cases := map[string]Observation{ |
| 58 | "stopped": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameStopped), "i-0abc", "eu-west-1"), |
| 59 | "stopping": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameStopping), "i-0abc", "eu-west-1"), |
| 60 | "shutting-down": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameShuttingDown), "i-0abc", "eu-west-1"), |
| 61 | "running": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameRunning), "i-0abc", "eu-west-1"), |
| 62 | "another account": FromDescribe(describe("999999999999", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1"), |
| 63 | "another region": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "us-east-1"), |
| 64 | "another instance": FromDescribe(describe("111122223333", "i-0zzz", types.InstanceStateNameTerminated), "i-0zzz", "eu-west-1"), |
| 65 | "not in the reply": FromDescribe(describe("111122223333", "i-0zzz", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1"), |
| 66 | "empty reply": FromDescribe(&ec2.DescribeInstancesOutput{}, "i-0abc", "eu-west-1"), |
| 67 | "nil reply": FromDescribe(nil, "i-0abc", "eu-west-1"), |
| 68 | "no state": FromDescribe(&ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("111122223333"), Instances: []types.Instance{{InstanceId: aws.String("i-0abc")}}}}}, "i-0abc", "eu-west-1"), |
| 69 | "unknown source": {Via: "cloudtrail", Found: true, InstanceID: "i-0abc", Account: "111122223333", Region: "eu-west-1", State: "terminated"}, |
| 70 | "zero observation": {}, |
| 71 | } |
| 72 | for name, o := range cases { |
| 73 | t.Run(name, func(t *testing.T) { |
| 74 | if rec, err := Verify(box, worker, o, now); !errors.Is(err, ErrNotEvidence) { |
| 75 | t.Fatalf("record = %+v, err = %v; want ErrNotEvidence", rec, err) |
| 76 | } |
| 77 | }) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // Review Focus 5: the fence is a regional worker's own read in the box's |
| 82 | // own region. A worker enrolled elsewhere is refused even when what it |
| 83 | // read is a perfect terminated observation. |
| 84 | func TestAWorkerInAnotherRegionNeverFences(t *testing.T) { |
| 85 | far := worker |
| 86 | far.Cloud.Region = "us-east-1" |
| 87 | o := FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1") |
| 88 | if _, err := Verify(box, far, o, now); !errors.Is(err, ErrNotEvidence) { |
| 89 | t.Fatalf("err = %v, want ErrNotEvidence", err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestAnIncompleteRosterEntryIsNeverFenced(t *testing.T) { |
| 94 | // A found, terminated observation whose every coordinate is empty, so |
| 95 | // it MATCHES an empty cloud field for field. Only the guard on the |
| 96 | // entry itself can refuse it. |
| 97 | o := Observation{Via: ViaDescribe, Found: true, State: "terminated"} |
| 98 | for name, b := range map[string]Box{ |
| 99 | "empty cloud": {Ref: "annie-welding", Enrollment: "enr-annie-1"}, |
| 100 | "no enrollment": {Ref: "annie-welding", Cloud: box.Cloud}, |
| 101 | "no reference": {Enrollment: "enr-annie-1", Cloud: box.Cloud}, |
| 102 | "not aws": {Ref: "annie-welding", Enrollment: "enr-annie-1", Cloud: boxid.Cloud{Provider: "gcp", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}}, |
| 103 | } { |
| 104 | t.Run(name, func(t *testing.T) { |
| 105 | full := Observation{Via: ViaDescribe, Found: true, InstanceID: b.Cloud.InstanceID, Account: b.Cloud.Account, Region: b.Cloud.Region, State: "terminated"} |
| 106 | for _, obs := range []Observation{o, full} { |
| 107 | if _, err := Verify(b, worker, obs, now); !errors.Is(err, ErrNotEvidence) { |
| 108 | t.Fatalf("err = %v, want ErrNotEvidence", err) |
| 109 | } |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | if _, err := Verify(box, Box{Ref: "bert-sawing"}, FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1"), now); !errors.Is(err, ErrNotEvidence) { |
| 114 | t.Fatalf("a worker with no proven identity: err = %v", err) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | const terminatedEvent = `{"version":"0","id":"ev-123","detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","time":"2026-10-06T11:59:00Z","region":"eu-west-1","resources":["arn:aws:ec2:eu-west-1:111122223333:instance/i-0abc"],"detail":{"instance-id":"i-0abc","state":"terminated"}}` |
| 119 | |
| 120 | func TestATerminatedEventIsAFence(t *testing.T) { |
| 121 | o, err := FromEvent([]byte(terminatedEvent)) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | rec, err := Verify(box, worker, o, now) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if rec.Via != "event" || rec.EventID != "ev-123" || rec.State != "terminated" || rec.RequestID != "" { |
| 130 | t.Fatalf("record = %+v", rec) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestEventsThatAreNotEC2StateChangesAreRefused(t *testing.T) { |
| 135 | for name, body := range map[string]string{ |
| 136 | "not JSON": `terminated`, |
| 137 | "another source": `{"id":"e","detail-type":"EC2 Instance State-change Notification","source":"my.app","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`, |
| 138 | "another detail-type": `{"id":"e","detail-type":"EC2 Spot Instance Interruption Warning","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`, |
| 139 | "no event id": `{"detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`, |
| 140 | } { |
| 141 | t.Run(name, func(t *testing.T) { |
| 142 | if _, err := FromEvent([]byte(body)); err == nil { |
| 143 | t.Fatal("accepted") |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestAShuttingDownEventIsNotEvidence(t *testing.T) { |
| 150 | body := `{"id":"e","detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"shutting-down"}}` |
| 151 | o, err := FromEvent([]byte(body)) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | if _, err := Verify(box, worker, o, now); !errors.Is(err, ErrNotEvidence) { |
| 156 | t.Fatalf("err = %v, want ErrNotEvidence", err) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // fakeEC2 answers DescribeInstances and records the region each call was |
| 161 | // forced into. |
| 162 | type fakeEC2 struct { |
| 163 | out *ec2.DescribeInstancesOutput |
| 164 | err error |
| 165 | regions []string |
| 166 | ids [][]string |
| 167 | } |
| 168 | |
| 169 | func (f *fakeEC2) DescribeInstances(_ context.Context, in *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| 170 | o := ec2.Options{Region: "client-default"} |
| 171 | for _, fn := range optFns { |
| 172 | fn(&o) |
| 173 | } |
| 174 | f.regions = append(f.regions, o.Region) |
| 175 | f.ids = append(f.ids, in.InstanceIds) |
| 176 | return f.out, f.err |
| 177 | } |
| 178 | |
| 179 | func TestDescribeInstanceAsksInTheBoxsOwnRegion(t *testing.T) { |
| 180 | f := &fakeEC2{out: describe("111122223333", "i-0abc", types.InstanceStateNameTerminated)} |
| 181 | out, err := DescribeInstance(context.Background(), f, box.Cloud) |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if len(f.regions) != 1 || f.regions[0] != "eu-west-1" || len(f.ids[0]) != 1 || f.ids[0][0] != "i-0abc" { |
| 186 | t.Fatalf("called in %v for %v", f.regions, f.ids) |
| 187 | } |
| 188 | if _, err := Verify(box, worker, FromDescribe(out, "i-0abc", "eu-west-1"), now); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestDescribeInstanceNotFoundIsAbsenceAndOtherErrorsAreErrors(t *testing.T) { |
| 194 | notFound := &fakeEC2{err: &smithy.GenericAPIError{Code: "InvalidInstanceID.NotFound", Message: "gone"}} |
| 195 | out, err := DescribeInstance(context.Background(), notFound, box.Cloud) |
| 196 | if err != nil { |
| 197 | t.Fatalf("NotFound: err = %v", err) |
| 198 | } |
| 199 | if _, err := Verify(box, worker, FromDescribe(out, "i-0abc", "eu-west-1"), now); !errors.Is(err, ErrNotEvidence) { |
| 200 | t.Fatalf("NotFound fenced the box: err = %v", err) |
| 201 | } |
| 202 | denied := &fakeEC2{err: &smithy.GenericAPIError{Code: "UnauthorizedOperation", Message: "no"}} |
| 203 | if _, err := DescribeInstance(context.Background(), denied, box.Cloud); err == nil { |
| 204 | t.Fatal("an access error read as an answer") |
| 205 | } |
| 206 | if _, err := DescribeInstance(context.Background(), &fakeEC2{}, boxid.Cloud{InstanceID: "i-0abc"}); err == nil { |
| 207 | t.Fatal("described an incomplete cloud") |
| 208 | } |
| 209 | } |
| 210 | |