| 1 | package mail |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "reflect" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| 11 | "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| 12 | sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" |
| 13 | ) |
| 14 | |
| 15 | // "Already exists" is a SUCCESS. Every walk re-runs after a half-failure, |
| 16 | // and a second EnsureIdentity that errors would make a crashed run |
| 17 | // unrepairable except by hand. |
| 18 | func TestAlreadyExistsIsRecognised(t *testing.T) { |
| 19 | if !isAlreadyExists(&sesv2types.AlreadyExistsException{}) { |
| 20 | t.Fatal("SES AlreadyExistsException not recognised") |
| 21 | } |
| 22 | if !isAlreadyExists(&iamtypes.EntityAlreadyExistsException{}) { |
| 23 | t.Fatal("IAM EntityAlreadyExistsException not recognised") |
| 24 | } |
| 25 | if isAlreadyExists(errors.New("some other failure")) { |
| 26 | t.Fatal("an unrelated error was treated as already-exists") |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // "Already gone" is a SUCCESS, for the same reason in the other direction: |
| 31 | // teardown must be re-runnable. |
| 32 | func TestNotFoundIsRecognised(t *testing.T) { |
| 33 | if !isNotFound(&sesv2types.NotFoundException{}) { |
| 34 | t.Fatal("SES NotFoundException not recognised") |
| 35 | } |
| 36 | if !isNotFound(&iamtypes.NoSuchEntityException{}) { |
| 37 | t.Fatal("IAM NoSuchEntityException not recognised") |
| 38 | } |
| 39 | if isNotFound(errors.New("some other failure")) { |
| 40 | t.Fatal("an unrelated error was treated as not-found") |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // BehaviorOnMxFailure must be REJECT_MESSAGE, not USE_DEFAULT_VALUE. |
| 45 | // |
| 46 | // USE_DEFAULT_VALUE silently falls back to amazonses.com when the custom |
| 47 | // MAIL FROM MX is missing — which means a misconfigured tenant keeps |
| 48 | // sending, from an unaligned envelope domain, failing DMARC, and nothing |
| 49 | // surfaces the fault. Rejecting is what turns a DNS mistake into an error |
| 50 | // somebody sees. |
| 51 | func TestMailFromFailureRejectsRatherThanFallsBack(t *testing.T) { |
| 52 | if mailFromBehaviour != sesv2types.BehaviorOnMxFailureRejectMessage { |
| 53 | t.Fatalf("BehaviorOnMxFailure = %v, want REJECT_MESSAGE", mailFromBehaviour) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // stubSES is a minimal sesAPI double: only the methods a given test cares |
| 58 | // about are wired via the function fields; everything else answers with a |
| 59 | // zero value and a nil error, which is fine because no test below relies on |
| 60 | // those paths. |
| 61 | type stubSES struct { |
| 62 | createTenant func(*sesv2.CreateTenantInput) (*sesv2.CreateTenantOutput, error) |
| 63 | createEmailIdentity func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) |
| 64 | getEmailIdentity func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) |
| 65 | putEmailIdentityMailFromAttributes func(*sesv2.PutEmailIdentityMailFromAttributesInput) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) |
| 66 | putEmailIdentityConfigurationSet func(*sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) |
| 67 | } |
| 68 | |
| 69 | func (s *stubSES) CreateTenant(_ context.Context, in *sesv2.CreateTenantInput, _ ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error) { |
| 70 | if s.createTenant != nil { |
| 71 | return s.createTenant(in) |
| 72 | } |
| 73 | return &sesv2.CreateTenantOutput{}, nil |
| 74 | } |
| 75 | |
| 76 | func (s *stubSES) CreateEmailIdentity(_ context.Context, in *sesv2.CreateEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) { |
| 77 | if s.createEmailIdentity != nil { |
| 78 | return s.createEmailIdentity(in) |
| 79 | } |
| 80 | return &sesv2.CreateEmailIdentityOutput{}, nil |
| 81 | } |
| 82 | |
| 83 | func (s *stubSES) GetEmailIdentity(_ context.Context, in *sesv2.GetEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) { |
| 84 | if s.getEmailIdentity != nil { |
| 85 | return s.getEmailIdentity(in) |
| 86 | } |
| 87 | return &sesv2.GetEmailIdentityOutput{}, nil |
| 88 | } |
| 89 | |
| 90 | func (s *stubSES) PutEmailIdentityMailFromAttributes(_ context.Context, in *sesv2.PutEmailIdentityMailFromAttributesInput, _ ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) { |
| 91 | if s.putEmailIdentityMailFromAttributes != nil { |
| 92 | return s.putEmailIdentityMailFromAttributes(in) |
| 93 | } |
| 94 | return &sesv2.PutEmailIdentityMailFromAttributesOutput{}, nil |
| 95 | } |
| 96 | |
| 97 | func (s *stubSES) DeleteEmailIdentity(_ context.Context, in *sesv2.DeleteEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error) { |
| 98 | return &sesv2.DeleteEmailIdentityOutput{}, nil |
| 99 | } |
| 100 | |
| 101 | func (s *stubSES) CreateConfigurationSet(_ context.Context, in *sesv2.CreateConfigurationSetInput, _ ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) { |
| 102 | return &sesv2.CreateConfigurationSetOutput{}, nil |
| 103 | } |
| 104 | |
| 105 | func (s *stubSES) CreateConfigurationSetEventDestination(_ context.Context, in *sesv2.CreateConfigurationSetEventDestinationInput, _ ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) { |
| 106 | return &sesv2.CreateConfigurationSetEventDestinationOutput{}, nil |
| 107 | } |
| 108 | |
| 109 | func (s *stubSES) PutEmailIdentityConfigurationSetAttributes(_ context.Context, in *sesv2.PutEmailIdentityConfigurationSetAttributesInput, _ ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| 110 | if s.putEmailIdentityConfigurationSet != nil { |
| 111 | return s.putEmailIdentityConfigurationSet(in) |
| 112 | } |
| 113 | return &sesv2.PutEmailIdentityConfigurationSetAttributesOutput{}, nil |
| 114 | } |
| 115 | |
| 116 | func (s *stubSES) CreateTenantResourceAssociation(_ context.Context, in *sesv2.CreateTenantResourceAssociationInput, _ ...func(*sesv2.Options)) (*sesv2.CreateTenantResourceAssociationOutput, error) { |
| 117 | return &sesv2.CreateTenantResourceAssociationOutput{}, nil |
| 118 | } |
| 119 | |
| 120 | func (s *stubSES) DeleteTenantResourceAssociation(_ context.Context, in *sesv2.DeleteTenantResourceAssociationInput, _ ...func(*sesv2.Options)) (*sesv2.DeleteTenantResourceAssociationOutput, error) { |
| 121 | return &sesv2.DeleteTenantResourceAssociationOutput{}, nil |
| 122 | } |
| 123 | |
| 124 | var _ sesAPI = (*stubSES)(nil) |
| 125 | |
| 126 | // TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN pins the |
| 127 | // F3-critical path: the ruling's own words are that getting this wrong means |
| 128 | // every IAM policy names a resource that does not exist, and this was |
| 129 | // otherwise untested until Task 12. It exercises the already-exists branch |
| 130 | // (CreateEmailIdentity fails, GetEmailIdentity is read back for the DKIM |
| 131 | // tokens already published in DNS), checks the exact composed ARN, and — |
| 132 | // the assertion that actually matters, because it pins aws.go and policy.go |
| 133 | // together — checks that ARN is ACCEPTED by SendPolicyJSON. |
| 134 | func TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN(t *testing.T) { |
| 135 | wantTokens := []string{"tok1", "tok2", "tok3"} |
| 136 | stub := &stubSES{ |
| 137 | createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| 138 | return nil, &sesv2types.AlreadyExistsException{Message: aws.String("identity already exists")} |
| 139 | }, |
| 140 | getEmailIdentity: func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) { |
| 141 | return &sesv2.GetEmailIdentityOutput{ |
| 142 | DkimAttributes: &sesv2types.DkimAttributes{Tokens: wantTokens}, |
| 143 | }, nil |
| 144 | }, |
| 145 | } |
| 146 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 147 | |
| 148 | got, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com", true) |
| 149 | if err != nil { |
| 150 | t.Fatalf("EnsureIdentity: %v", err) |
| 151 | } |
| 152 | if !reflect.DeepEqual(got.DKIMTokens, wantTokens) { |
| 153 | t.Fatalf("DKIMTokens = %v, want the ones read back from GetEmailIdentity %v", got.DKIMTokens, wantTokens) |
| 154 | } |
| 155 | |
| 156 | const wantARN = "arn:aws:ses:eu-west-1:123456789012:identity/example.com" |
| 157 | if got.ARN != wantARN { |
| 158 | t.Fatalf("ARN = %q, want %q", got.ARN, wantARN) |
| 159 | } |
| 160 | |
| 161 | // The assertion that actually matters: the composed ARN must satisfy |
| 162 | // SendPolicyJSON's shape validator, since EnsureIdentity's return value |
| 163 | // flows straight into EnsureUser -> SendPolicyJSON in production. A |
| 164 | // composed ARN that validator rejects would make every send fail with |
| 165 | // no way to notice until Task 12. |
| 166 | if _, err := SendPolicyJSON([]string{got.ARN}, nil, []string{"example.com"}); err != nil { |
| 167 | t.Fatalf("composed ARN %q was rejected by SendPolicyJSON: %v", got.ARN, err) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // TestEnsureIdentityWithoutAdoptLeavesAnExistingIdentityAlone (#216): with |
| 172 | // adopt false, an identity that already exists may belong to another product |
| 173 | // in this AWS account, so it is refused before it is read or its MAIL FROM |
| 174 | // rewritten. |
| 175 | func TestEnsureIdentityWithoutAdoptLeavesAnExistingIdentityAlone(t *testing.T) { |
| 176 | touched := false |
| 177 | stub := &stubSES{ |
| 178 | createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| 179 | return nil, &sesv2types.AlreadyExistsException{Message: aws.String("identity already exists")} |
| 180 | }, |
| 181 | getEmailIdentity: func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) { |
| 182 | touched = true |
| 183 | return &sesv2.GetEmailIdentityOutput{}, nil |
| 184 | }, |
| 185 | putEmailIdentityMailFromAttributes: func(*sesv2.PutEmailIdentityMailFromAttributesInput) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) { |
| 186 | touched = true |
| 187 | return &sesv2.PutEmailIdentityMailFromAttributesOutput{}, nil |
| 188 | }, |
| 189 | } |
| 190 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 191 | if _, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com", false); !errors.Is(err, ErrIdentityExists) { |
| 192 | t.Fatalf("EnsureIdentity(adopt=false) on an existing identity = %v, want ErrIdentityExists", err) |
| 193 | } |
| 194 | if touched { |
| 195 | t.Fatal("an existing identity was read or rewritten without adopt") |
| 196 | } |
| 197 | |
| 198 | stub.createEmailIdentity = func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| 199 | return &sesv2.CreateEmailIdentityOutput{DkimAttributes: &sesv2types.DkimAttributes{Tokens: []string{"a", "b", "c"}}}, nil |
| 200 | } |
| 201 | got, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com", false) |
| 202 | if err != nil || !got.Created { |
| 203 | t.Fatalf("a fresh create = %+v, %v; want Created", got, err) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // TestEnsureIdentityRefusesNoTokens is the other half of the same path: an |
| 208 | // identity that reads back with no DKIM tokens (BYODKIM, an email-address |
| 209 | // identity, or a nil DkimAttributes) must not be reported as success — the |
| 210 | // walk would publish no CNAMEs and the domain could never converge, with |
| 211 | // nothing surfacing why. |
| 212 | func TestEnsureIdentityRefusesNoTokens(t *testing.T) { |
| 213 | stub := &stubSES{ |
| 214 | createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| 215 | return &sesv2.CreateEmailIdentityOutput{}, nil |
| 216 | }, |
| 217 | } |
| 218 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 219 | |
| 220 | if _, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com", true); err == nil { |
| 221 | t.Fatal("EnsureIdentity with zero DKIM tokens returned success, want an error") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // TestSetIdentityConfigSetCallsThroughWithTheRightNames (C1, review |
| 226 | // follow-up): this is the ONLY call in the whole package that actually |
| 227 | // binds an identity to the configuration set carrying SES's event |
| 228 | // destination — without it SES never emits an event for that identity, no |
| 229 | // matter how many messages it sends. Pinned here at the AWS-facing layer, |
| 230 | // not just against the in-memory fake, so a call built with the identity |
| 231 | // and set-name arguments swapped (or dropped) would fail this test. |
| 232 | func TestSetIdentityConfigSetCallsThroughWithTheRightNames(t *testing.T) { |
| 233 | var gotIdentity, gotConfigSet string |
| 234 | stub := &stubSES{ |
| 235 | putEmailIdentityConfigurationSet: func(in *sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| 236 | gotIdentity = aws.ToString(in.EmailIdentity) |
| 237 | gotConfigSet = aws.ToString(in.ConfigurationSetName) |
| 238 | return &sesv2.PutEmailIdentityConfigurationSetAttributesOutput{}, nil |
| 239 | }, |
| 240 | } |
| 241 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 242 | |
| 243 | if err := p.SetIdentityConfigSet(context.Background(), "eu-west-1", "example.com", "carlos-bag"); err != nil { |
| 244 | t.Fatalf("SetIdentityConfigSet: %v", err) |
| 245 | } |
| 246 | if gotIdentity != "example.com" { |
| 247 | t.Fatalf("EmailIdentity = %q, want %q", gotIdentity, "example.com") |
| 248 | } |
| 249 | if gotConfigSet != "carlos-bag" { |
| 250 | t.Fatalf("ConfigurationSetName = %q, want %q", gotConfigSet, "carlos-bag") |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // A backend failure (e.g. the identity does not exist) must propagate, not |
| 255 | // be swallowed as though the binding succeeded — a silently-not-bound |
| 256 | // identity is exactly the C1 defect this whole verb exists to close. |
| 257 | func TestSetIdentityConfigSetPropagatesBackendError(t *testing.T) { |
| 258 | stub := &stubSES{ |
| 259 | putEmailIdentityConfigurationSet: func(*sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| 260 | return nil, &sesv2types.NotFoundException{Message: aws.String("no such identity")} |
| 261 | }, |
| 262 | } |
| 263 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 264 | |
| 265 | if err := p.SetIdentityConfigSet(context.Background(), "eu-west-1", "example.com", "carlos-bag"); err == nil { |
| 266 | t.Fatal("SetIdentityConfigSet swallowed a backend error") |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | // wellFormedIdentityStub returns a stubSES whose CreateEmailIdentity and |
| 271 | // PutEmailIdentityMailFromAttributes both succeed, with three non-empty |
| 272 | // DKIM tokens. Used by the region-guard tests below so that success or |
| 273 | // failure turns ENTIRELY on checkRegion and never on fix #3's zero-token |
| 274 | // guard — round 1's TestEnsureIdentityRefusesAMismatchedRegion used a bare |
| 275 | // &stubSES{}, whose zero-value CreateEmailIdentity success carries no |
| 276 | // DkimAttributes; the zero-token guard fired and satisfied "an error came |
| 277 | // back" even with checkRegion's call site deleted entirely, so the test |
| 278 | // passed for the wrong reason. Confirmed empirically below (see the |
| 279 | // round-2 fix report) by removing the checkRegion call and rerunning. |
| 280 | func wellFormedIdentityStub() *stubSES { |
| 281 | return &stubSES{ |
| 282 | createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| 283 | return &sesv2.CreateEmailIdentityOutput{ |
| 284 | DkimAttributes: &sesv2types.DkimAttributes{Tokens: []string{"t1", "t2", "t3"}}, |
| 285 | }, nil |
| 286 | }, |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // TestRegionGuardRefusesAMismatch covers all four SES-facing methods that |
| 291 | // take a region parameter (EnsureConfigSet, EnsureIdentity, IdentityStatus, |
| 292 | // DeleteIdentity): a caller naming a region other than the one the |
| 293 | // provisioner is bound to must be refused, and the refusal must be |
| 294 | // checkRegion's — not an error that happens to fire for an unrelated |
| 295 | // reason. Every stub here answers with an unconditional success for |
| 296 | // whatever SES call the guard is supposed to prevent from ever being |
| 297 | // reached, so if the guard is silently removed the call SUCCEEDS instead of |
| 298 | // merely "erroring somehow" — which is what makes the errors.Is assertion |
| 299 | // meaningful rather than vacuous. |
| 300 | func TestRegionGuardRefusesAMismatch(t *testing.T) { |
| 301 | const boundRegion = "eu-west-1" |
| 302 | const otherRegion = "us-east-1" |
| 303 | |
| 304 | cases := []struct { |
| 305 | name string |
| 306 | call func(p *awsProvisioner) error |
| 307 | }{ |
| 308 | {"EnsureConfigSet", func(p *awsProvisioner) error { |
| 309 | return p.EnsureConfigSet(context.Background(), otherRegion, "cfg", "arn:aws:sns:eu-west-1:123456789012:topic") |
| 310 | }}, |
| 311 | {"EnsureIdentity", func(p *awsProvisioner) error { |
| 312 | _, err := p.EnsureIdentity(context.Background(), otherRegion, "example.com", "mail.example.com", true) |
| 313 | return err |
| 314 | }}, |
| 315 | {"IdentityStatus", func(p *awsProvisioner) error { |
| 316 | _, err := p.IdentityStatus(context.Background(), otherRegion, "example.com") |
| 317 | return err |
| 318 | }}, |
| 319 | {"DeleteIdentity", func(p *awsProvisioner) error { |
| 320 | return p.DeleteIdentity(context.Background(), otherRegion, "example.com") |
| 321 | }}, |
| 322 | } |
| 323 | |
| 324 | for _, tc := range cases { |
| 325 | t.Run(tc.name, func(t *testing.T) { |
| 326 | p := &awsProvisioner{ses: wellFormedIdentityStub(), account: "123456789012", region: boundRegion} |
| 327 | err := tc.call(p) |
| 328 | if err == nil { |
| 329 | t.Fatalf("%s accepted region %q against a provisioner bound to %q", tc.name, otherRegion, boundRegion) |
| 330 | } |
| 331 | if !errors.Is(err, errRegionMismatch) { |
| 332 | t.Fatalf("%s error = %v, want it to wrap errRegionMismatch", tc.name, err) |
| 333 | } |
| 334 | }) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // TestRegionGuardPassesAnEmptyRegionThrough pins the deliberate escape: |
| 339 | // checkRegion treats "" as "caller doesn't care" rather than a mismatch. |
| 340 | // Each case uses a stub that succeeds unconditionally, so a passing test |
| 341 | // here means the guard did not fire — not that some other stub default |
| 342 | // happened to swallow an error. |
| 343 | func TestRegionGuardPassesAnEmptyRegionThrough(t *testing.T) { |
| 344 | const boundRegion = "eu-west-1" |
| 345 | |
| 346 | cases := []struct { |
| 347 | name string |
| 348 | call func(p *awsProvisioner) error |
| 349 | }{ |
| 350 | {"EnsureConfigSet", func(p *awsProvisioner) error { |
| 351 | return p.EnsureConfigSet(context.Background(), "", "cfg", "arn:aws:sns:eu-west-1:123456789012:topic") |
| 352 | }}, |
| 353 | {"EnsureIdentity", func(p *awsProvisioner) error { |
| 354 | _, err := p.EnsureIdentity(context.Background(), "", "example.com", "mail.example.com", true) |
| 355 | return err |
| 356 | }}, |
| 357 | {"IdentityStatus", func(p *awsProvisioner) error { |
| 358 | _, err := p.IdentityStatus(context.Background(), "", "example.com") |
| 359 | return err |
| 360 | }}, |
| 361 | {"DeleteIdentity", func(p *awsProvisioner) error { |
| 362 | return p.DeleteIdentity(context.Background(), "", "example.com") |
| 363 | }}, |
| 364 | } |
| 365 | |
| 366 | for _, tc := range cases { |
| 367 | t.Run(tc.name, func(t *testing.T) { |
| 368 | p := &awsProvisioner{ses: wellFormedIdentityStub(), account: "123456789012", region: boundRegion} |
| 369 | if err := tc.call(p); err != nil { |
| 370 | t.Fatalf("%s with an empty region was refused: %v", tc.name, err) |
| 371 | } |
| 372 | }) |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // TestEnsureTenantCallsCreateTenantAndAcceptsAlreadyExists exercises |
| 377 | // EnsureTenant, which added CreateTenant to sesAPI and was otherwise never |
| 378 | // stubbed anywhere. |
| 379 | func TestEnsureTenantCallsCreateTenantAndAcceptsAlreadyExists(t *testing.T) { |
| 380 | var gotName string |
| 381 | stub := &stubSES{ |
| 382 | createTenant: func(in *sesv2.CreateTenantInput) (*sesv2.CreateTenantOutput, error) { |
| 383 | gotName = aws.ToString(in.TenantName) |
| 384 | return nil, &sesv2types.AlreadyExistsException{Message: aws.String("tenant already exists")} |
| 385 | }, |
| 386 | } |
| 387 | p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| 388 | |
| 389 | if err := p.EnsureTenant(context.Background(), "acct-42"); err != nil { |
| 390 | t.Fatalf("EnsureTenant: %v", err) |
| 391 | } |
| 392 | if gotName != "acct-42" { |
| 393 | t.Fatalf("CreateTenant called with TenantName %q, want %q", gotName, "acct-42") |
| 394 | } |
| 395 | } |
| 396 | |