| 1 | package objstore |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/aws/aws-sdk-go-v2/aws" |
| 9 | "github.com/aws/aws-sdk-go-v2/service/iam" |
| 10 | iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| 11 | awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| 12 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| 13 | "github.com/aws/smithy-go" |
| 14 | ) |
| 15 | |
| 16 | // TestNewAWSProvisioner is construction-only: it must succeed offline (the |
| 17 | // default credential chain loads without ever making a call), and the |
| 18 | // returned value must satisfy Provisioner. |
| 19 | func TestNewAWSProvisioner(t *testing.T) { |
| 20 | p, err := NewAWSProvisioner(context.Background(), "eu-west-1", "", "") |
| 21 | if err != nil { |
| 22 | t.Fatalf("NewAWSProvisioner: %v", err) |
| 23 | } |
| 24 | if p == nil { |
| 25 | t.Fatal("NewAWSProvisioner returned nil Provisioner") |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | // TestNewAWSProvisionerEndpointOptions checks the four s3compat-mirrored |
| 30 | // options land on the real client when endpoint is set: BaseEndpoint + |
| 31 | // UsePathStyle (addressing) and both checksum-WhenRequired settings (the |
| 32 | // pair that keeps default-on flexible checksums from wrapping bodies in |
| 33 | // aws-chunked framing some S3-compatibles reject — DeleteObjects, EmptyBucket's |
| 34 | // batch delete, is exactly a checksummed request). White-box: reaches into |
| 35 | // the unexported s3 field because this is the only seam that observes what |
| 36 | // awss3.NewFromConfig actually built, short of a live request. |
| 37 | func TestNewAWSProvisionerEndpointOptions(t *testing.T) { |
| 38 | prov, err := NewAWSProvisioner(context.Background(), "eu-west-1", "http://127.0.0.1:9000", "") |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | p, ok := prov.(*awsProvisioner) |
| 43 | if !ok { |
| 44 | t.Fatalf("NewAWSProvisioner returned %T, want *awsProvisioner", prov) |
| 45 | } |
| 46 | client, ok := p.s3.(*awss3.Client) |
| 47 | if !ok { |
| 48 | t.Fatalf("p.s3 is %T, want *s3.Client", p.s3) |
| 49 | } |
| 50 | opts := client.Options() |
| 51 | if opts.BaseEndpoint == nil || *opts.BaseEndpoint != "http://127.0.0.1:9000" { |
| 52 | t.Fatalf("BaseEndpoint = %v, want http://127.0.0.1:9000", opts.BaseEndpoint) |
| 53 | } |
| 54 | if !opts.UsePathStyle { |
| 55 | t.Fatal("UsePathStyle = false, want true") |
| 56 | } |
| 57 | if opts.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired { |
| 58 | t.Fatalf("RequestChecksumCalculation = %v, want WhenRequired", opts.RequestChecksumCalculation) |
| 59 | } |
| 60 | if opts.ResponseChecksumValidation != aws.ResponseChecksumValidationWhenRequired { |
| 61 | t.Fatalf("ResponseChecksumValidation = %v, want WhenRequired", opts.ResponseChecksumValidation) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // TestNewAWSProvisionerNoEndpointOptions checks the stock-AWS path (empty |
| 66 | // endpoint) leaves those four options untouched, matching s3compat.Options' |
| 67 | // own no-op guarantee for a client that talks to real S3. |
| 68 | func TestNewAWSProvisionerNoEndpointOptions(t *testing.T) { |
| 69 | prov, err := NewAWSProvisioner(context.Background(), "eu-west-1", "", "") |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | p := prov.(*awsProvisioner) |
| 74 | client := p.s3.(*awss3.Client) |
| 75 | opts := client.Options() |
| 76 | if opts.BaseEndpoint != nil { |
| 77 | t.Fatalf("BaseEndpoint = %v, want nil", *opts.BaseEndpoint) |
| 78 | } |
| 79 | if opts.UsePathStyle { |
| 80 | t.Fatal("UsePathStyle = true, want false") |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // ---- stub clients ------------------------------------------------------- |
| 85 | // |
| 86 | // Each stub implements the narrow s3API/iamAPI seam with per-method hooks. |
| 87 | // An un-set hook fails the test loudly (via t.Fatalf) rather than panicking |
| 88 | // on a nil func — a test that calls a method it didn't mean to exercise |
| 89 | // should say so. |
| 90 | |
| 91 | type stubS3 struct { |
| 92 | t *testing.T |
| 93 | |
| 94 | createBucket func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) |
| 95 | deleteBucket func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error) |
| 96 | listObjectsV2 func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) |
| 97 | deleteObjects func(*awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) |
| 98 | } |
| 99 | |
| 100 | func (s *stubS3) CreateBucket(_ context.Context, in *awss3.CreateBucketInput, _ ...func(*awss3.Options)) (*awss3.CreateBucketOutput, error) { |
| 101 | if s.createBucket == nil { |
| 102 | s.t.Fatal("unexpected CreateBucket call") |
| 103 | } |
| 104 | return s.createBucket(in) |
| 105 | } |
| 106 | |
| 107 | func (s *stubS3) DeleteBucket(_ context.Context, in *awss3.DeleteBucketInput, _ ...func(*awss3.Options)) (*awss3.DeleteBucketOutput, error) { |
| 108 | if s.deleteBucket == nil { |
| 109 | s.t.Fatal("unexpected DeleteBucket call") |
| 110 | } |
| 111 | return s.deleteBucket(in) |
| 112 | } |
| 113 | |
| 114 | func (s *stubS3) ListObjectsV2(_ context.Context, in *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) { |
| 115 | if s.listObjectsV2 == nil { |
| 116 | s.t.Fatal("unexpected ListObjectsV2 call") |
| 117 | } |
| 118 | return s.listObjectsV2(in) |
| 119 | } |
| 120 | |
| 121 | func (s *stubS3) DeleteObjects(_ context.Context, in *awss3.DeleteObjectsInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectsOutput, error) { |
| 122 | if s.deleteObjects == nil { |
| 123 | s.t.Fatal("unexpected DeleteObjects call") |
| 124 | } |
| 125 | return s.deleteObjects(in) |
| 126 | } |
| 127 | |
| 128 | type stubIAM struct { |
| 129 | t *testing.T |
| 130 | |
| 131 | createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) |
| 132 | getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error) |
| 133 | putUserPolicy func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) |
| 134 | deleteUserPolicy func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) |
| 135 | deleteUser func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error) |
| 136 | createAccessKey func(*iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) |
| 137 | deleteAccessKey func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) |
| 138 | listAccessKeys func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) |
| 139 | } |
| 140 | |
| 141 | func (s *stubIAM) CreateUser(_ context.Context, in *iam.CreateUserInput, _ ...func(*iam.Options)) (*iam.CreateUserOutput, error) { |
| 142 | if s.createUser == nil { |
| 143 | s.t.Fatal("unexpected CreateUser call") |
| 144 | } |
| 145 | return s.createUser(in) |
| 146 | } |
| 147 | |
| 148 | func (s *stubIAM) GetUser(_ context.Context, in *iam.GetUserInput, _ ...func(*iam.Options)) (*iam.GetUserOutput, error) { |
| 149 | if s.getUser == nil { |
| 150 | s.t.Fatal("unexpected GetUser call") |
| 151 | } |
| 152 | return s.getUser(in) |
| 153 | } |
| 154 | |
| 155 | func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _ ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) { |
| 156 | if s.putUserPolicy == nil { |
| 157 | s.t.Fatal("unexpected PutUserPolicy call") |
| 158 | } |
| 159 | return s.putUserPolicy(in) |
| 160 | } |
| 161 | |
| 162 | func (s *stubIAM) DeleteUserPolicy(_ context.Context, in *iam.DeleteUserPolicyInput, _ ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) { |
| 163 | if s.deleteUserPolicy == nil { |
| 164 | s.t.Fatal("unexpected DeleteUserPolicy call") |
| 165 | } |
| 166 | return s.deleteUserPolicy(in) |
| 167 | } |
| 168 | |
| 169 | func (s *stubIAM) DeleteUser(_ context.Context, in *iam.DeleteUserInput, _ ...func(*iam.Options)) (*iam.DeleteUserOutput, error) { |
| 170 | if s.deleteUser == nil { |
| 171 | s.t.Fatal("unexpected DeleteUser call") |
| 172 | } |
| 173 | return s.deleteUser(in) |
| 174 | } |
| 175 | |
| 176 | func (s *stubIAM) CreateAccessKey(_ context.Context, in *iam.CreateAccessKeyInput, _ ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { |
| 177 | if s.createAccessKey == nil { |
| 178 | s.t.Fatal("unexpected CreateAccessKey call") |
| 179 | } |
| 180 | return s.createAccessKey(in) |
| 181 | } |
| 182 | |
| 183 | func (s *stubIAM) DeleteAccessKey(_ context.Context, in *iam.DeleteAccessKeyInput, _ ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { |
| 184 | if s.deleteAccessKey == nil { |
| 185 | s.t.Fatal("unexpected DeleteAccessKey call") |
| 186 | } |
| 187 | return s.deleteAccessKey(in) |
| 188 | } |
| 189 | |
| 190 | func (s *stubIAM) ListAccessKeys(_ context.Context, in *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { |
| 191 | if s.listAccessKeys == nil { |
| 192 | s.t.Fatal("unexpected ListAccessKeys call") |
| 193 | } |
| 194 | return s.listAccessKeys(in) |
| 195 | } |
| 196 | |
| 197 | // ---- idempotence mappings (HARD constraints from Tasks 4-6) ------------- |
| 198 | |
| 199 | // DeleteKey on an absent key (NoSuchEntityException) must be nil: both |
| 200 | // RotateFinish's PrevKeyID escape hatch and Rotate step-1 re-run this against |
| 201 | // a key that is already gone. |
| 202 | func TestDeleteKeyAbsentIsNil(t *testing.T) { |
| 203 | p := &awsProvisioner{iam: &stubIAM{t: t, |
| 204 | deleteAccessKey: func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| 205 | return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| 206 | }, |
| 207 | }} |
| 208 | if err := p.DeleteKey(context.Background(), "u1", "AKIAABSENT"); err != nil { |
| 209 | t.Fatalf("DeleteKey on absent key: %v", err) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // DeleteUser on an absent user must be nil, and must delete access keys and |
| 214 | // the inline policy BEFORE DeleteUser — IAM refuses to delete a user that |
| 215 | // still owns either, and this is also where a pending-finish PrevKeyID gets |
| 216 | // reaped during teardown. |
| 217 | func TestDeleteUserAbsentIsNilAndOrdered(t *testing.T) { |
| 218 | var order []string |
| 219 | stub := &stubIAM{t: t, |
| 220 | listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| 221 | order = append(order, "list") |
| 222 | return &iam.ListAccessKeysOutput{ |
| 223 | AccessKeyMetadata: []iamtypes.AccessKeyMetadata{ |
| 224 | {AccessKeyId: aws.String("AKIAPENDING")}, |
| 225 | }, |
| 226 | IsTruncated: false, |
| 227 | }, nil |
| 228 | }, |
| 229 | deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| 230 | order = append(order, "deleteKey:"+aws.ToString(in.AccessKeyId)) |
| 231 | return &iam.DeleteAccessKeyOutput{}, nil |
| 232 | }, |
| 233 | deleteUserPolicy: func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { |
| 234 | order = append(order, "deletePolicy") |
| 235 | return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| 236 | }, |
| 237 | deleteUser: func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error) { |
| 238 | order = append(order, "deleteUser") |
| 239 | return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| 240 | }, |
| 241 | } |
| 242 | p := &awsProvisioner{iam: stub} |
| 243 | if err := p.DeleteUser(context.Background(), "u1"); err != nil { |
| 244 | t.Fatalf("DeleteUser on absent user: %v", err) |
| 245 | } |
| 246 | want := []string{"list", "deleteKey:AKIAPENDING", "deletePolicy", "deleteUser"} |
| 247 | if len(order) != len(want) { |
| 248 | t.Fatalf("call order = %v, want %v", order, want) |
| 249 | } |
| 250 | for i := range want { |
| 251 | if order[i] != want[i] { |
| 252 | t.Fatalf("call order = %v, want %v", order, want) |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // ReapKeys on an absent user (NoSuchEntity from ListAccessKeys) must be nil. |
| 258 | func TestReapKeysAbsentUserIsNil(t *testing.T) { |
| 259 | p := &awsProvisioner{iam: &stubIAM{t: t, |
| 260 | listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| 261 | return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| 262 | }, |
| 263 | }} |
| 264 | if err := p.ReapKeys(context.Background(), "ghost", []string{"keep-me"}); err != nil { |
| 265 | t.Fatalf("ReapKeys on absent user: %v", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // ReapKeys deletes every key not in keep and leaves the kept ones alone. |
| 270 | func TestReapKeysDeletesUnkept(t *testing.T) { |
| 271 | deleted := map[string]bool{} |
| 272 | p := &awsProvisioner{iam: &stubIAM{t: t, |
| 273 | listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| 274 | return &iam.ListAccessKeysOutput{ |
| 275 | AccessKeyMetadata: []iamtypes.AccessKeyMetadata{ |
| 276 | {AccessKeyId: aws.String("KEEP")}, |
| 277 | {AccessKeyId: aws.String("REAP1")}, |
| 278 | {AccessKeyId: aws.String("REAP2")}, |
| 279 | }, |
| 280 | IsTruncated: false, |
| 281 | }, nil |
| 282 | }, |
| 283 | deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| 284 | deleted[aws.ToString(in.AccessKeyId)] = true |
| 285 | return &iam.DeleteAccessKeyOutput{}, nil |
| 286 | }, |
| 287 | }} |
| 288 | if err := p.ReapKeys(context.Background(), "u1", []string{"KEEP"}); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | if !deleted["REAP1"] || !deleted["REAP2"] { |
| 292 | t.Fatalf("expected REAP1 and REAP2 deleted, got %v", deleted) |
| 293 | } |
| 294 | if deleted["KEEP"] { |
| 295 | t.Fatal("KEEP should not have been deleted") |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // EmptyBucket on an absent bucket must be nil, including when the NoSuchBucket |
| 300 | // only surfaces on the ListObjectsV2 leg (as a smithy APIError, not the typed |
| 301 | // s3types.NoSuchBucket) — teardown resume after a crash between DeleteBucket |
| 302 | // and DeleteUser re-runs EmptyBucket on a gone bucket. |
| 303 | func TestEmptyBucketAbsentIsNil(t *testing.T) { |
| 304 | p := &awsProvisioner{s3: &stubS3{t: t, |
| 305 | listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| 306 | return nil, &smithyAPIErrorStub{code: "NoSuchBucket", msg: "The specified bucket does not exist"} |
| 307 | }, |
| 308 | }} |
| 309 | if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil { |
| 310 | t.Fatalf("EmptyBucket on absent bucket: %v", err) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // Same, but via the typed s3types.NoSuchBucket rather than a generic |
| 315 | // smithy.APIError — both forms must be recognized. |
| 316 | func TestEmptyBucketAbsentTypedErrorIsNil(t *testing.T) { |
| 317 | p := &awsProvisioner{s3: &stubS3{t: t, |
| 318 | listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| 319 | return nil, &s3types.NoSuchBucket{Message: aws.String("gone")} |
| 320 | }, |
| 321 | }} |
| 322 | if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil { |
| 323 | t.Fatalf("EmptyBucket on absent bucket (typed): %v", err) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // EmptyBucket batches DeleteObjects at <=1000 keys per call and follows |
| 328 | // ListObjectsV2 pagination. |
| 329 | func TestEmptyBucketBatchesAndPaginates(t *testing.T) { |
| 330 | page1 := make([]s3types.Object, 1500) // exceeds the 1000 batch ceiling |
| 331 | for i := range page1 { |
| 332 | page1[i] = s3types.Object{Key: aws.String("obj1500")} |
| 333 | } |
| 334 | page2 := []s3types.Object{{Key: aws.String("obj-page2")}} |
| 335 | |
| 336 | var listCalls int |
| 337 | var deleteBatchSizes []int |
| 338 | p := &awsProvisioner{s3: &stubS3{t: t, |
| 339 | listObjectsV2: func(in *awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| 340 | listCalls++ |
| 341 | if listCalls == 1 { |
| 342 | return &awss3.ListObjectsV2Output{ |
| 343 | Contents: page1, |
| 344 | IsTruncated: aws.Bool(true), |
| 345 | NextContinuationToken: aws.String("page2"), |
| 346 | }, nil |
| 347 | } |
| 348 | if aws.ToString(in.ContinuationToken) != "page2" { |
| 349 | t.Fatalf("expected continuation token page2, got %q", aws.ToString(in.ContinuationToken)) |
| 350 | } |
| 351 | return &awss3.ListObjectsV2Output{ |
| 352 | Contents: page2, |
| 353 | IsTruncated: aws.Bool(false), |
| 354 | }, nil |
| 355 | }, |
| 356 | deleteObjects: func(in *awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) { |
| 357 | deleteBatchSizes = append(deleteBatchSizes, len(in.Delete.Objects)) |
| 358 | return &awss3.DeleteObjectsOutput{}, nil |
| 359 | }, |
| 360 | }} |
| 361 | if err := p.EmptyBucket(context.Background(), "b1"); err != nil { |
| 362 | t.Fatal(err) |
| 363 | } |
| 364 | if listCalls != 2 { |
| 365 | t.Fatalf("listCalls = %d, want 2", listCalls) |
| 366 | } |
| 367 | want := []int{1000, 500, 1} |
| 368 | if len(deleteBatchSizes) != len(want) { |
| 369 | t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want) |
| 370 | } |
| 371 | for i := range want { |
| 372 | if deleteBatchSizes[i] != want[i] { |
| 373 | t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want) |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // DeleteObjects is a 200-with-body API: a per-key failure lands in |
| 379 | // out.Errors while the call itself reports no err. EmptyBucket must surface |
| 380 | // that as an error naming the count and the first failing key, rather than |
| 381 | // silently returning nil with objects still in the bucket — a caller that |
| 382 | // swallowed this would hang forever retrying DeleteBucket's later |
| 383 | // BucketNotEmpty with no clue which object is stuck. |
| 384 | func TestEmptyBucketSurfacesPerKeyDeleteErrors(t *testing.T) { |
| 385 | p := &awsProvisioner{s3: &stubS3{t: t, |
| 386 | listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| 387 | return &awss3.ListObjectsV2Output{ |
| 388 | Contents: []s3types.Object{ |
| 389 | {Key: aws.String("locked-obj")}, |
| 390 | {Key: aws.String("other-obj")}, |
| 391 | }, |
| 392 | IsTruncated: aws.Bool(false), |
| 393 | }, nil |
| 394 | }, |
| 395 | deleteObjects: func(*awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) { |
| 396 | return &awss3.DeleteObjectsOutput{ |
| 397 | Errors: []s3types.Error{ |
| 398 | {Key: aws.String("locked-obj"), Code: aws.String("AccessDenied"), Message: aws.String("object is under legal hold")}, |
| 399 | }, |
| 400 | }, nil |
| 401 | }, |
| 402 | }} |
| 403 | err := p.EmptyBucket(context.Background(), "b1") |
| 404 | if err == nil { |
| 405 | t.Fatal("expected an error from a DeleteObjects batch with populated Errors, got nil") |
| 406 | } |
| 407 | const want = `objstore: delete objects in "b1": 1 failed, first locked-obj: AccessDenied (object is under legal hold)` |
| 408 | if err.Error() != want { |
| 409 | t.Fatalf("error = %q, want %q", err.Error(), want) |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | // DeleteBucket on an absent bucket must be nil. |
| 414 | func TestDeleteBucketAbsentIsNil(t *testing.T) { |
| 415 | p := &awsProvisioner{s3: &stubS3{t: t, |
| 416 | deleteBucket: func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error) { |
| 417 | return nil, &s3types.NoSuchBucket{Message: aws.String("gone")} |
| 418 | }, |
| 419 | }} |
| 420 | if err := p.DeleteBucket(context.Background(), "gone-bucket"); err != nil { |
| 421 | t.Fatalf("DeleteBucket on absent bucket: %v", err) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | // EnsureBucket treats BucketAlreadyOwnedByYou as success and answers the |
| 426 | // configured region. |
| 427 | func TestEnsureBucketAlreadyOwnedByYou(t *testing.T) { |
| 428 | p := &awsProvisioner{region: "eu-west-1", s3: &stubS3{t: t, |
| 429 | createBucket: func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) { |
| 430 | return nil, &s3types.BucketAlreadyOwnedByYou{Message: aws.String("already yours")} |
| 431 | }, |
| 432 | }} |
| 433 | region, err := p.EnsureBucket(context.Background(), "b1") |
| 434 | if err != nil { |
| 435 | t.Fatalf("EnsureBucket on already-owned bucket: %v", err) |
| 436 | } |
| 437 | if region != "eu-west-1" { |
| 438 | t.Fatalf("region = %q, want eu-west-1", region) |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // EnsureBucket does NOT set a location constraint for us-east-1 (the region |
| 443 | // with no LocationConstraint value of its own). |
| 444 | func TestEnsureBucketUsEast1NoLocationConstraint(t *testing.T) { |
| 445 | var gotConstraint bool |
| 446 | p := &awsProvisioner{region: "us-east-1", s3: &stubS3{t: t, |
| 447 | createBucket: func(in *awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) { |
| 448 | gotConstraint = in.CreateBucketConfiguration != nil |
| 449 | return &awss3.CreateBucketOutput{}, nil |
| 450 | }, |
| 451 | }} |
| 452 | if _, err := p.EnsureBucket(context.Background(), "b1"); err != nil { |
| 453 | t.Fatal(err) |
| 454 | } |
| 455 | if gotConstraint { |
| 456 | t.Fatal("us-east-1 must not set CreateBucketConfiguration") |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | // EnsureUser: EntityAlreadyExists resolves the ARN via GetUser, and |
| 461 | // PutUserPolicy always runs (idempotent overwrite) regardless of which |
| 462 | // branch produced the ARN. |
| 463 | func TestEnsureUserAlreadyExists(t *testing.T) { |
| 464 | var putPolicyCalled bool |
| 465 | var putDoc string |
| 466 | p := &awsProvisioner{iam: &stubIAM{t: t, |
| 467 | createUser: func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| 468 | return nil, &iamtypes.EntityAlreadyExistsException{Message: aws.String("exists")} |
| 469 | }, |
| 470 | getUser: func(in *iam.GetUserInput) (*iam.GetUserOutput, error) { |
| 471 | return &iam.GetUserOutput{User: &iamtypes.User{ |
| 472 | Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"), |
| 473 | UserName: in.UserName, |
| 474 | }}, nil |
| 475 | }, |
| 476 | putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| 477 | putPolicyCalled = true |
| 478 | putDoc = aws.ToString(in.PolicyDocument) |
| 479 | return &iam.PutUserPolicyOutput{}, nil |
| 480 | }, |
| 481 | }} |
| 482 | arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1", nil) |
| 483 | if err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" { |
| 487 | t.Fatalf("arn = %q", arn) |
| 488 | } |
| 489 | if !putPolicyCalled { |
| 490 | t.Fatal("PutUserPolicy must run unconditionally, even on the already-exists branch") |
| 491 | } |
| 492 | assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1") |
| 493 | } |
| 494 | |
| 495 | // EnsureUser on a fresh user: ARN comes from CreateUser's own response, and |
| 496 | // the policy document matches the brief exactly. |
| 497 | func TestEnsureUserFresh(t *testing.T) { |
| 498 | var putDoc string |
| 499 | p := &awsProvisioner{iam: &stubIAM{t: t, |
| 500 | createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| 501 | return &iam.CreateUserOutput{User: &iamtypes.User{ |
| 502 | Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"), |
| 503 | UserName: in.UserName, |
| 504 | Path: in.Path, |
| 505 | }}, nil |
| 506 | }, |
| 507 | putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| 508 | if aws.ToString(in.PolicyName) != policyName { |
| 509 | t.Fatalf("policy name = %q, want %q", aws.ToString(in.PolicyName), policyName) |
| 510 | } |
| 511 | putDoc = aws.ToString(in.PolicyDocument) |
| 512 | return &iam.PutUserPolicyOutput{}, nil |
| 513 | }, |
| 514 | }} |
| 515 | arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1", nil) |
| 516 | if err != nil { |
| 517 | t.Fatal(err) |
| 518 | } |
| 519 | if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" { |
| 520 | t.Fatalf("arn = %q", arn) |
| 521 | } |
| 522 | assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1") |
| 523 | } |
| 524 | |
| 525 | // assertStorePolicyDocument checks doc matches the brief's policy shape |
| 526 | // exactly: ListBucket on bucketARN, Get/Put/DeleteObject on bucketARN+"/*". |
| 527 | func assertStorePolicyDocument(t *testing.T, doc, bucketARN string) { |
| 528 | t.Helper() |
| 529 | var parsed struct { |
| 530 | Version string `json:"Version"` |
| 531 | Statement []struct { |
| 532 | Effect string `json:"Effect"` |
| 533 | Action []string `json:"Action"` |
| 534 | Resource string `json:"Resource"` |
| 535 | } `json:"Statement"` |
| 536 | } |
| 537 | if err := json.Unmarshal([]byte(doc), &parsed); err != nil { |
| 538 | t.Fatalf("policy document not valid JSON: %v\n%s", err, doc) |
| 539 | } |
| 540 | if parsed.Version != "2012-10-17" { |
| 541 | t.Fatalf("Version = %q", parsed.Version) |
| 542 | } |
| 543 | // Two Allows, then the two anti-forgery Denys that every store carries |
| 544 | // whether or not enforcement is on. This helper checks the Allow half |
| 545 | // exactly; assertNoPutObjectTagging (enforce_test.go) owns the Deny half, |
| 546 | // including the x-amz-tagging route that an action-name search cannot see. |
| 547 | if len(parsed.Statement) != 4 { |
| 548 | t.Fatalf("Statement has %d entries, want 4 (two Allows, two anti-forgery Denys):\n%s", len(parsed.Statement), doc) |
| 549 | } |
| 550 | list, objects := parsed.Statement[0], parsed.Statement[1] |
| 551 | if list.Effect != "Allow" || len(list.Action) != 1 || list.Action[0] != "s3:ListBucket" || list.Resource != bucketARN { |
| 552 | t.Fatalf("ListBucket statement = %+v", list) |
| 553 | } |
| 554 | // s3:GetObjectTagging is here so an app can read the scan verdict on its |
| 555 | // own object. s3:PutObjectTagging is deliberately absent and must stay |
| 556 | // absent — but absence alone was never the whole property, and believing |
| 557 | // it was left the x-amz-tagging upload route open. storePolicyJSON's |
| 558 | // comment has the story; the Denys are what actually close it. |
| 559 | wantActions := map[string]bool{ |
| 560 | "s3:GetObject": true, "s3:PutObject": true, "s3:DeleteObject": true, |
| 561 | "s3:GetObjectTagging": true, |
| 562 | } |
| 563 | if objects.Effect != "Allow" || len(objects.Action) != 4 || objects.Resource != bucketARN+"/*" { |
| 564 | t.Fatalf("object statement = %+v", objects) |
| 565 | } |
| 566 | for _, a := range objects.Action { |
| 567 | if !wantActions[a] { |
| 568 | t.Fatalf("unexpected action %q in object statement", a) |
| 569 | } |
| 570 | } |
| 571 | assertNoPutObjectTagging(t, doc) |
| 572 | } |
| 573 | |
| 574 | // smithyAPIErrorStub is a minimal smithy.APIError for exercising the code |
| 575 | // path where ListObjectsV2 answers NoSuchBucket as a generic API error |
| 576 | // rather than the typed s3types.NoSuchBucket. |
| 577 | type smithyAPIErrorStub struct { |
| 578 | code string |
| 579 | msg string |
| 580 | } |
| 581 | |
| 582 | func (e *smithyAPIErrorStub) Error() string { return e.code + ": " + e.msg } |
| 583 | func (e *smithyAPIErrorStub) ErrorCode() string { return e.code } |
| 584 | func (e *smithyAPIErrorStub) ErrorMessage() string { return e.msg } |
| 585 | func (e *smithyAPIErrorStub) ErrorFault() smithy.ErrorFault { return smithy.FaultUnknown } |
| 586 | |
| 587 | var _ smithy.APIError = (*smithyAPIErrorStub)(nil) |
| 588 | |
| 589 | // TestEnsureUserStampsThePermissionsBoundary: the boundary is the only thing |
| 590 | // standing between "this role may create IAM users" and "this role may create |
| 591 | // an IAM user with any policy at all", so it has to reach CreateUser on the |
| 592 | // exact call that makes the principal — not be attached afterwards, where a |
| 593 | // crash in between leaves an unbounded user holding a live access key. |
| 594 | func TestEnsureUserStampsThePermissionsBoundary(t *testing.T) { |
| 595 | const boundary = "arn:aws:iam::1:policy/carlos-store-boundary" |
| 596 | var seen *string |
| 597 | iamStub := &stubIAM{ |
| 598 | t: t, |
| 599 | createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| 600 | seen = in.PermissionsBoundary |
| 601 | return &iam.CreateUserOutput{User: &iamtypes.User{ |
| 602 | Arn: aws.String("arn:aws:iam::1:user/carlos-store/carlos-store-bab-titogo"), |
| 603 | }}, nil |
| 604 | }, |
| 605 | putUserPolicy: func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| 606 | return &iam.PutUserPolicyOutput{}, nil |
| 607 | }, |
| 608 | } |
| 609 | p := &awsProvisioner{iam: iamStub, region: "eu-west-1", boundary: boundary} |
| 610 | if _, err := p.EnsureUser(context.Background(), "carlos-store-bab-titogo", UserPath, "arn:aws:s3:::carlos-bab-titogo", nil); err != nil { |
| 611 | t.Fatalf("EnsureUser: %v", err) |
| 612 | } |
| 613 | if seen == nil { |
| 614 | t.Fatal("CreateUser was called with NO permissions boundary — the deployment's IAM will refuse this call, and if it ever stops refusing, the user is unbounded") |
| 615 | } |
| 616 | if *seen != boundary { |
| 617 | t.Fatalf("PermissionsBoundary = %q, want %q", *seen, boundary) |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | // An empty boundary must send no field at all rather than an empty string: |
| 622 | // MemProvisioner and the contract tests construct provisioners with no IAM, |
| 623 | // and CreateUser rejects an empty PermissionsBoundary outright. |
| 624 | func TestEnsureUserOmitsAnEmptyBoundary(t *testing.T) { |
| 625 | sent := true |
| 626 | iamStub := &stubIAM{ |
| 627 | t: t, |
| 628 | createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| 629 | sent = in.PermissionsBoundary != nil |
| 630 | return &iam.CreateUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::1:user/x")}}, nil |
| 631 | }, |
| 632 | putUserPolicy: func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| 633 | return &iam.PutUserPolicyOutput{}, nil |
| 634 | }, |
| 635 | } |
| 636 | p := &awsProvisioner{iam: iamStub, region: "eu-west-1"} |
| 637 | if _, err := p.EnsureUser(context.Background(), "u", UserPath, "arn:aws:s3:::b", nil); err != nil { |
| 638 | t.Fatalf("EnsureUser: %v", err) |
| 639 | } |
| 640 | if sent { |
| 641 | t.Fatal("PermissionsBoundary was set from an empty boundary; want the field omitted") |
| 642 | } |
| 643 | } |
| 644 | |